/* こえろぐ(koelog) フロント（React 18 UMD + Babel standalone／genba frontend と同型）。
   ★本丸＝「声から自動仕分け」：でかいボタン1回 → 録音 → もう1回 → 文字起こし＋要約＋
     「誰の（お客様）／いつの（日付）／誰が録ったか（端末に紐付いた録音者）」まで自動でタイムラインに収まる。
     ・お客様を事前に選ばせない（運転中に手操作ゼロ）。入口＝POST /api/handoffs/auto。
     ・名前が取れない／同名が複数 → 「未分類」トレイに落として消さない（後から人が振り分ける）。
   ★録音まわり（pickRecordMime / MediaRecorder / upload）は genba の HandoffRecorder 由来。
     Home（自動仕分け）と Timeline（お客様を指定して録る）で同じ useRecorder を共有する。 */
const { useState, useEffect, useRef } = React;

/* ───────── 共通スタイル ───────── */
const S = {
  page: { maxWidth: 720, margin: "0 auto", padding: "16px 14px 64px" },
  card: { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14,
          padding: 16, boxShadow: "var(--shadow)" },
  h1: { fontSize: 20, fontWeight: 800, margin: 0, letterSpacing: ".02em" },
  sub: { fontSize: 12, color: "var(--text-dim)", marginTop: 2 },
  label: { fontSize: 12, fontWeight: 700, color: "var(--text-dim)", marginBottom: 6, display: "block" },
  input: { width: "100%", boxSizing: "border-box", background: "var(--surface)",
           border: "1.5px solid var(--border)", borderRadius: 10, color: "var(--text)",
           padding: "12px 12px", fontSize: 16, outline: "none" },
  btn: { width: "100%", boxSizing: "border-box", background: "var(--accent)", color: "var(--on-accent)",
         border: "none", borderRadius: 12, padding: "14px 16px", fontSize: 16, fontWeight: 800,
         cursor: "pointer" },
  btnGhost: { width: "100%", boxSizing: "border-box", background: "transparent", color: "var(--text-2)",
              border: "1.5px solid var(--border)", borderRadius: 12, padding: "12px 16px",
              fontSize: 14, fontWeight: 700, cursor: "pointer" },
  /* ★③トグル録音＝でかいボタン1個。運転中に画面を見ずに指1本で当てられる面積を確保する
     （minHeight 210＝スマホ画面のおよそ1/4。カードの一番上に置くので位置も毎回同じ）。 */
  btnToggle: { width: "100%", boxSizing: "border-box", minHeight: 210, border: "none", borderRadius: 20,
               color: "var(--on-accent)", cursor: "pointer", display: "flex", flexDirection: "column",
               alignItems: "center", justifyContent: "center", gap: 4, padding: "18px 12px",
               textAlign: "center", letterSpacing: ".03em", touchAction: "manipulation",
               WebkitUserSelect: "none", userSelect: "none" },
  toggleIdle: { background: "var(--accent)" },
  toggleOn: { background: "var(--danger)", boxShadow: "0 0 0 6px rgba(192,57,43,.18)" },
  toggleBusy: { background: "var(--text-dimmer)", cursor: "default" },
  chip: { fontSize: 11, fontWeight: 800, color: "var(--gold)", background: "var(--gold-soft)",
          borderRadius: 999, padding: "3px 10px" },
  chipDim: { fontSize: 11, fontWeight: 800, color: "var(--text-dim)", background: "var(--sunken)",
             borderRadius: 999, padding: "3px 10px" },
  chipOk: { fontSize: 11, fontWeight: 800, color: "var(--ok)", background: "var(--ok-soft)",
            borderRadius: 999, padding: "3px 10px" },
};

const apiGET = (u) => fetch(withDev(u), { credentials: "same-origin" }).then((r) => r.json());

/* ★D：見る人が誰かをサーバへ伝える（可視マトリクスの判定に使う）。
   端末記憶の memberId / 名前 / 端末ID / 区分 / 役職 を付ける。
   ※これは「見せる量を絞る」ための申告であって、認証ではない（ログインは会社単位）。 */
const viewerQuery = () => {
  const d = loadDevice() || {};
  const p = new URLSearchParams();
  if (d.memberId) p.set("vid", String(d.memberId));
  if (d.name) p.set("vby", d.name);
  if (d.id) p.set("vdev", d.id);
  if (d.kind) p.set("vkind", d.kind);
  if (d.role) p.set("vrole", d.role);
  const q = p.toString();
  return q ? q : "";
};
const withViewer = (u) => {
  const q = viewerQuery();
  if (!q) return u;
  return u + (u.indexOf("?") >= 0 ? "&" : "?") + q;
};
/* ★H-1：どのAPIにも「この端末は誰か」(vdev)を載せる。サーバの端末の鍵(DEVICE_GATE)は
   この申告が届いて初めて「承認済みかどうか」を見られる（鍵が off の間は何も起きない＝従来どおり）。
   ・既に vdev が付いているURL（withViewer 経由）には足さない
   ・端末を設定していない時は何も足さない＝ログイン前・初回ウィザード前も従来どおり */
const withDev = (u) => {
  const d = loadDevice() || {};
  if (!d.id || String(u).indexOf("vdev=") >= 0) return u;
  return u + (u.indexOf("?") >= 0 ? "&" : "?") + "vdev=" + encodeURIComponent(d.id);
};

const apiJSON = (u, method, body) =>
  fetch(withDev(u), { method, credentials: "same-origin", headers: { "Content-Type": "application/json" },
             body: JSON.stringify(body || {}) })
    .then((r) => r.json().catch(() => null).then((j) => ({ status: r.status, j })));

const fmtAt = (s) => {
  // サーバは UTC の ISO を返す＝端末のローカル時刻で読める形に直す（失敗したら素の先頭16字）。
  const t = String(s || "");
  const d = new Date(t);
  if (t && !isNaN(d.getTime())) {
    const p = (n) => String(n).padStart(2, "0");
    return d.getFullYear() + "/" + p(d.getMonth() + 1) + "/" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes());
  }
  return t.length >= 16 ? t.slice(0, 16).replace("T", " ") : t;
};

/* ★②「いつの話か」＝サーバが実日付(YYYY-MM-DD)で持つ。表示は 9/3(木) の形。 */
const fmtDay = (s) => {
  const m = String(s || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!m) return "";
  const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  if (isNaN(d.getTime())) return "";
  return Number(m[2]) + "/" + Number(m[3]) + "(" + "日月火水木金土"[d.getDay()] + ")";
};

/* ───────── 🔊 予定の読み上げ（運転中に耳だけで確かめる） ─────────
   ★ブラウザに元から入っている音声合成(speechSynthesis)だけを使う＝サーバもAIも1回も増やさない（追加コスト0）。
   ★勝手に喋らない：自動再生はせず、必ずボタンを押した時だけ発話する（店内・接客中の暴発を防ぐ）。
   ★非対応ブラウザ（古いiOS等）では、そもそもボタンを出さない＝押しても壊れる余地を作らない。 */
const ttsOK = () => {
  try {
    return typeof window !== "undefined" && !!window.speechSynthesis
           && typeof window.SpeechSynthesisUtterance === "function";
  } catch (e) { return false; }
};
const ttsStop = () => { try { if (ttsOK()) window.speechSynthesis.cancel(); } catch (e) {} };

/* "2026-09-20" → "9月20日 日曜日"（耳で日付を取り違えないよう曜日まで言う） */
const sayDay = (s) => {
  const m = String(s || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!m) return "";
  const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  if (isNaN(d.getTime())) return Number(m[2]) + "月" + Number(m[3]) + "日";
  return Number(m[2]) + "月" + Number(m[3]) + "日 " + "日月火水木金土"[d.getDay()] + "曜日";
};
/* "19:30" → "19時30分"／"19:00" → "19時"／無ければ "時間は未定" */
const sayTime = (t) => {
  const m = String(t || "").match(/^(\d{2}):(\d{2})$/);
  if (!m) return "時間は未定";
  const mi = Number(m[2]);
  return Number(m[1]) + "時" + (mi ? mi + "分" : "");
};
/* ★カレンダーの本文と同じ文を、耳で聞ける形に整える。
   箇条書きの「- 」等を落とし、改行は句点に変えて自然に切る（読点だらけの棒読みを避ける）。 */
const sayBody = (t) => String(t || "")
  .split(/\n+/)
  .map((x) => x.replace(/^[\s　]*[-*・●○◆■▪]+[\s　]*/, "").trim())
  .filter(Boolean)
  .map((x) => (/[。．.!?！？]$/.test(x) ? x : x + "。"))
  .join("");

/* ★読み上げる「本文」＝Googleカレンダーの description に入れているのと【同じフィールド・同じ優先順・同じ上限】。
   backend/app.py:1319 の desc = "こえろぐの申し送りから自動作成\n" + (summary or transcript)[:400] と一致させる
   ＝「カレンダーに入る内容」と「耳で聞く内容」がズレない。 */
const calendarBodyOf = (o) => sayBody(String((o && (o.summary || o.transcript)) || "").slice(0, 400));

const sayName = (n) => {
  const t = String(n || "").trim();
  if (!t) return "";
  return /[様さん]$/.test(t) ? t : t + "さん";
};
/* 読み上げる文を組み立てる。★新しいデータは取りに行かない＝画面が既に持っている events / summary だけを使う。
   予定があれば予定を最優先（「何を登録したか」の確認が目的）／無ければ AI要約を読む。 */
const speakTextOf = (o) => {
  if (!o) return "";
  const evs = (o.events || []).filter((e) => e && e.date);
  if (evs.length) {
    const head = evs.length > 1 ? "予定が" + evs.length + "件あります。" : "予定です。";
    const heads = head + evs.map((e, i) => {
      const who = sayName(e.customer) || sayName(e.who);
      const kind = String(e.kind || "").trim();
      const title = String(e.title || "").trim();
      /* ★同じ言葉を二度言わない：タイトルから「種類」と「お客様名」を抜いて、まだ中身が残る時だけ読む
         （例 title="山本 同伴" は種類と客名で言い尽くしているので読まない／"空港まで送迎" は読む）。 */
      const rest = title.split(kind).join("").split(String(e.customer || "").trim()).join("")
                        .replace(/[様さん\s　：:、,.・]/g, "");
      const body = [sayDay(e.date) + " " + sayTime(e.time),
                    kind && kind !== "その他" ? kind : "",
                    who,
                    (title && rest) ? title : ""]
                   .filter(Boolean).join("、");
      return (evs.length > 1 ? (i + 1) + "件目、" : "") + body + "。";
    }).join(" ");
    /* ★見出し（日付・時刻・種類・お客様）のあとに、カレンダー本文を続けて最後まで読む。
       ここを省くと「何の予定か」は分かっても「何をするか（迎えに行く／ボトルをサービス等）」が耳に入らない。 */
    const body = calendarBodyOf(o);
    /* ★入らなかった予定が1件でもあれば末尾で言う。運転中に「入ったつもり」で終わらせない。 */
    const failed = evs.filter((e) => e.gcalError && !e.gcalId).length;
    const warn = failed ? " ※カレンダーには登録できていません。" : "";
    return (body ? (heads + " " + body) : heads) + warn;
  }
  const sm = calendarBodyOf(o);
  return sm ? "予定はありません。要約を読みます。" + sm : "";
};

/* ★カレンダー登録の結果＝サーバが実際にGoogleの応答で判定して events に残した印だけで表示する。
   gcalId あり＝入った／gcalError あり＝入らなかった／どちらも無い＝まだ結果が出ていない（何も言わない）。
   ＝「投げっぱなしで成功扱い」をしない。 */
const calStatOf = (o) => {
  const evs = ((o && o.events) || []).filter((e) => e && e.date);
  const ok = evs.filter((e) => e.gcalId).length;
  const ng = evs.filter((e) => e.gcalError && !e.gcalId).length;
  const expired = evs.some((e) => !e.gcalId && (e.gcalError === "expired" || e.gcalError === "no_token"));
  return { total: evs.length, ok: ok, ng: ng, expired: expired, done: evs.length > 0 && ok + ng >= evs.length };
};

function CalendarResult({ src }) {
  const c = calStatOf(src);
  if (!c.total || (!c.ok && !c.ng)) return null;          // ★結果が出ていないうちは何も言わない
  const bad = c.ng > 0;
  return (
    <div style={{ marginTop: 10, padding: "8px 12px", borderRadius: 10, fontSize: 13, fontWeight: 800,
                  lineHeight: 1.7,
                  background: bad ? "var(--danger-soft)" : "var(--ok-soft)",
                  color: bad ? "var(--danger)" : "var(--ok)",
                  border: "1px solid " + (bad ? "var(--danger)" : "var(--ok)") }}>
      {bad
        ? (c.ok
            ? "⚠ カレンダーに登録できたのは " + c.ok + "件／" + c.ng + "件は登録できませんでした"
            : "⚠ カレンダーに登録できませんでした（" + c.ng + "件）")
        : "📅 カレンダーに " + c.ok + "件 登録しました"}
      {bad && c.expired
        ? <div style={{ fontSize: 12, fontWeight: 700, marginTop: 4 }}>
            Google連携が切れている可能性があります。設定から再連携してください。
          </div>
        : null}
    </div>
  );
}

/* 🔊ボタン本体。押す＝発話／発話中にもう一度押す＝停止（トグル）。画面を離れれば自動で止まる。 */
function SpeakButton({ src, style, big }) {
  const [on, setOn] = useState(false);
  useEffect(() => () => ttsStop(), []);          // ★アンマウント＝別画面へ移った時は必ず止める
  if (!ttsOK()) return null;                     // 非対応ブラウザ＝ボタンごと出さない
  const text = speakTextOf(src);
  if (!text) return null;                        // 読むものが無い（予定も要約も無い）＝出さない
  const toggle = () => {
    try {
      const ss = window.speechSynthesis;
      if (on || ss.speaking || ss.pending) { ss.cancel(); setOn(false); return; }
      const u = new window.SpeechSynthesisUtterance(text);
      u.lang = "ja-JP";                          // ★日本語の声で読ませる
      u.rate = 1;
      u.onend = () => setOn(false);
      u.onerror = () => setOn(false);
      ss.cancel();                               // 前の発話が残っていれば消してから（★iOSはこの後すぐ speak する）
      ss.speak(u);
      setOn(true);
    } catch (e) { setOn(false); }                // 喋れなくても画面は壊さない
  };
  /* ★big＝録音直後の結果カードの一番上に置く用。運転中にスクロールせず親指で押せる面積を確保する
     （minHeight 64＝でかい録音ボタンに次ぐ大きさ・カード幅いっぱい）。タイムライン側は従来どおり小さいまま。 */
  const base = big
    ? { ...S.btn, minHeight: 64, fontSize: 19, letterSpacing: ".04em", touchAction: "manipulation",
        WebkitUserSelect: "none", userSelect: "none", marginBottom: 12 }
    : { ...S.btnGhost, marginTop: 8 };
  return (
    <button style={{ ...base, ...(style || {}) }} onClick={toggle}
            aria-label={on ? "読み上げを止める" : "予定を読み上げる"}>
      {on ? "⏹ 読み上げを止める" : "🔊 読み上げ"}
    </button>
  );
}

/* ───────── ③録音者の個体識別＝「端末に紐付けた録音者名」 ─────────
   ★ブラウザは本当のハード固有番号を取れない。なので「この端末は誰か」を初回に1回だけ聞いて
     localStorage に永続保存し、以降の録音へ自動で付ける（ローカルアプリ前提なので可）。
   ★保存場所＝そのブラウザの localStorage、キー "koelog.device.v1"（{id, name}）。
     id＝端末を区別するための自前UUID（サーバへは deviceId として送るだけ・個人情報ではない）。 */
const DEV_KEY = "koelog.device.v1";
function loadDevice() {
  try {
    const j = JSON.parse(window.localStorage.getItem(DEV_KEY) || "null");
    if (j && typeof j === "object") return j;
  } catch (_e) {}
  return null;
}
function saveDevice(d) {
  try { window.localStorage.setItem(DEV_KEY, JSON.stringify(d)); } catch (_e) {}
  return d;
}
function newDeviceId() {
  try { if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); } catch (_e) {}
  return "dev-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
}

/* ───────── 入り口を「初回だけ」に＝ログイン済みの記憶 ─────────
   ★これは【鍵ではない】。本当の認証はサーバの署名付きセッションクッキー（HttpOnly＝JSから読めない）で、
     ここに置くのは「前回この端末でログインした会社はどこか」という“描画のヒント”だけ。
     だから起動時に読み込み画面もログイン画面も出さずにいきなりHome(🎤)を描ける。
     サーバが /api/me で認めなかったら即座に捨ててログイン画面へ戻す＝すっ飛ばせるのは通る端末だけ。
   ★保存場所＝localStorage キー "koelog.auth.v1"。中身は {id, name, is_demo, at} のみ。
     パスワード・トークン・APIキーの類は一切入れない（入れる必要がない）。 */
const AUTH_KEY = "koelog.auth.v1";
function loadAuth() {
  try {
    const j = JSON.parse(window.localStorage.getItem(AUTH_KEY) || "null");
    if (j && typeof j === "object" && j.id) return { id: j.id, name: j.name || "", is_demo: !!j.is_demo };
  } catch (_e) {}
  return null;
}
function saveAuth(co) {
  try {
    if (!co || !co.id) return co;
    window.localStorage.setItem(AUTH_KEY, JSON.stringify(
      { id: co.id, name: co.name || "", is_demo: !!co.is_demo, at: new Date().toISOString() }));
  } catch (_e) {}
  return co;
}
function clearAuth() {
  try { window.localStorage.removeItem(AUTH_KEY); } catch (_e) {}
}

/* ★iOS Safari は audio/mp4 のみ、Chrome/Firefox は audio/webm 系。実機が出せる先頭を選ぶ。
   空文字を返したら MediaRecorder に mimeType を渡さない＝ブラウザ既定に委ねる（最後の砦）。 */
function pickRecordMime() {
  const cands = ["audio/mp4", "audio/webm;codecs=opus", "audio/webm", "audio/mpeg", "audio/wav"];
  const MR = typeof window !== "undefined" && window.MediaRecorder;
  if (!MR || typeof MR.isTypeSupported !== "function") return "";
  for (let i = 0; i < cands.length; i++) { if (MR.isTypeSupported(cands[i])) return cands[i]; }
  return "";
}

/* ───────── 録音トグル本体（Home と Timeline で共有）─────────
   押す→録音開始／もう一度押す→停止→onBlob(blob)。中身は genba HandoffRecorder のまま。 */
function useRecorder(onBlob) {
  const [rec, setRec] = useState(false);
  const [secs, setSecs] = useState(0);
  const cbRef = useRef(onBlob); cbRef.current = onBlob;
  const recRef = useRef(null);
  const chunksRef = useRef([]);
  const streamRef = useRef(null);
  const timerRef = useRef(null);
  const audioCtxRef = useRef(null);

  /* ★録音中の画面スリープ抑止（Screen Wake Lock）。
     スマホが自動ロックすると録音が止まって申し送りが丸ごと消えるため、録音中だけ画面を起こしておく。
     ・対応端末（Android Chrome / iOS Safari 16.4以降）＝OSに「消さないで」と頼む
     ・非対応/拒否＝wake を "off" にして、録音中の画面に注意書きを出す（フォールバック）
     ★取得に失敗しても録音は絶対に止めない（頼みごとであって録音の前提ではない）。 */
  const wakeRef = useRef(null);
  const [wake, setWake] = useState("");            // "on"=抑止中 ／"off"=効かない ／""=録音していない

  const releaseWake = () => {
    const w = wakeRef.current;
    wakeRef.current = null;
    setWake("");
    try { if (w && !w.released) w.release(); } catch (_e) {}
  };
  const acquireWake = () => {
    try {
      if (!navigator.wakeLock || !navigator.wakeLock.request) { setWake("off"); return; }
      navigator.wakeLock.request("screen").then((w) => {
        wakeRef.current = w;
        setWake("on");
        // OSが勝手に外すことがある（他アプリへ切替など）。外れたら印を落とす。
        try { w.addEventListener("release", () => { if (wakeRef.current === w) { wakeRef.current = null; setWake("off"); } }); } catch (_e) {}
      }).catch(() => { setWake("off"); });
    } catch (_e) { setWake("off"); }
  };

  /* 他アプリから戻ってきた時に取り直す（OSに外されたままにしない）。録音中だけ試みる。 */
  useEffect(() => {
    const onVis = () => {
      if (!RecState.rec) return;
      if (document.visibilityState === "visible" && !wakeRef.current) acquireWake();
    };
    try { document.addEventListener("visibilitychange", onVis); } catch (_e) {}
    return () => { try { document.removeEventListener("visibilitychange", onVis); } catch (_e) {} };
  }, []);

  useEffect(() => () => {
    if (timerRef.current) clearInterval(timerRef.current);
    setRecording(false);
    releaseWake();                                  // 画面を離れたら必ず解除（電池を食わせない）
  }, []);

  const stopTracks = () => {
    try { (streamRef.current ? streamRef.current.getTracks() : []).forEach((t) => t.stop()); } catch (_e) {}
    streamRef.current = null;
  };

  /* ★押し損ね対策＝押した瞬間の合図。画面を見ないで使うため、当たったことを手か耳で分かるようにする。
       ・触覚＝navigator.vibrate（Android等。iOS Safari は非対応＝黙って無視される・無害）
       ・音  ＝WebAudio の短いビープ（開始=高い音/停止=低い音）。音源ファイル不要。
     ★どちらも try で囲い、失敗しても録音そのものには一切影響させない（合図は「保険」であって本体ではない）。 */
  const feedback = (kind) => {
    try { if (navigator.vibrate) navigator.vibrate(kind === "start" ? 60 : [40, 60, 40]); } catch (_e) {}
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return;
      if (!audioCtxRef.current) audioCtxRef.current = new AC();
      const ctx = audioCtxRef.current;
      if (ctx.state === "suspended") ctx.resume();   // iOSは押下(ユーザー操作)の中でだけ鳴らせる
      const o = ctx.createOscillator(), g = ctx.createGain();
      o.type = "sine";
      o.frequency.value = (kind === "start" ? 880 : 440);
      g.gain.setValueAtTime(0.0001, ctx.currentTime);
      g.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.01);
      g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.18);
      o.connect(g); g.connect(ctx.destination);
      o.start(); o.stop(ctx.currentTime + 0.2);
    } catch (_e) {}
  };

  const startRec = () => {
    if (rec) return;
    const nav = typeof navigator !== "undefined" ? navigator : null;
    if (!nav || !nav.mediaDevices || !nav.mediaDevices.getUserMedia || !(window.MediaRecorder)) {
      alert("この端末では録音できません（マイクに対応したブラウザでお試しください）。"); return;
    }
    nav.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
      streamRef.current = stream;
      const mime = pickRecordMime();
      let mr;
      try { mr = mime ? new window.MediaRecorder(stream, { mimeType: mime }) : new window.MediaRecorder(stream); }
      catch (_e) { try { mr = new window.MediaRecorder(stream); } catch (_e2) { stopTracks(); alert("録音を開始できませんでした。"); return; } }
      chunksRef.current = [];
      mr.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); };
      mr.onstop = () => {
        stopTracks();
        if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
        const type = (mr.mimeType || mime || "audio/webm");
        const blob = new Blob(chunksRef.current, { type: type });
        chunksRef.current = [];
        setRec(false); setRecording(false); releaseWake();
        if (!blob.size) { alert("音声が録れませんでした。もう一度お試しください。"); return; }
        if (cbRef.current) cbRef.current(blob);
      };
      recRef.current = mr;
      mr.start();
      setRec(true); setRecording(true); setSecs(0);
      acquireWake();                                // ★録音を始めた「後」に頼む＝失敗しても録音は続く
      timerRef.current = setInterval(() => setSecs((s) => s + 1), 1000);
    }).catch(() => { releaseWake(); alert("マイクを使えませんでした（マイクの許可をご確認ください）。"); });
  };
  const stopRec = () => { if (!rec) return; try { recRef.current && recRef.current.stop(); } catch (_e) { stopTracks(); setRec(false); setRecording(false); releaseWake(); } };

  const toggle = (blocked) => {
    if (blocked) return;
    if (rec) { feedback("stop"); stopRec(); } else { feedback("start"); startRec(); }
  };
  return { rec, secs, toggle, wake };
}

/* ───────── 画面の更新（リロード）─────────
   ★録音中は絶対にリロードさせない：録った音はまだブラウザのメモリの中だけにあり、
     ページを捨てた瞬間に消えるため（サーバにはまだ何も届いていない）。
   ★RecState はモジュール共有の旗。useRecorder が開始/停止で書き換え、
     ブラウザ自身の再読込（引っぱって更新・⌘R・戻る）にも beforeunload で警告を出す。 */
const RecState = { rec: false };

function setRecording(on) {
  RecState.rec = !!on;
  try {
    if (on) window.addEventListener("beforeunload", guardUnload);
    else window.removeEventListener("beforeunload", guardUnload);
  } catch (_e) {}
}
function guardUnload(e) {
  if (!RecState.rec) return;
  e.preventDefault();
  e.returnValue = "";        // ブラウザ標準の「このページを離れますか？」を出す
  return "";
}

/* どの画面のヘッダーにも置く小さな更新ボタン。画面全体を読み直して（リロードして）最新にする。 */
function RefreshButton({ rec, busy }) {
  const onClick = () => {
    if (rec || RecState.rec) {
      alert("録音中は更新できません。\n先に録音を止めて送信してください（更新すると録った音が消えます）。");
      return;
    }
    if (busy) {
      if (!window.confirm("処理中です。更新すると画面の結果表示は消えます。\n（記録そのものはサーバに残ります）\n更新しますか？")) return;
    }
    try { window.location.reload(); } catch (_e) {}
  };
  const off = !!(rec || busy);
  return (
    <button onClick={onClick} title="画面を最新にする" aria-label="画面を最新にする"
            style={{ width: 38, height: 38, flexShrink: 0, borderRadius: 10, cursor: off ? "not-allowed" : "pointer",
                     border: "1.5px solid var(--border)", background: "transparent",
                     color: off ? "var(--text-dimmer)" : "var(--text-2)", fontSize: 17, lineHeight: 1,
                     padding: 0, opacity: off ? .45 : 1 }}>🔄</button>
  );
}

const mmss = (n) => Math.floor(n / 60) + ":" + String(n % 60).padStart(2, "0");

/* 共通のでかいトグルボタン（見た目＝③のまま）。 */
function BigToggle({ rec, secs, busy, msg, onToggle, idleTitle, idleHint, wake }) {
  return (
    <button onClick={onToggle} disabled={busy}
            className={rec ? "rec-pulse" : undefined}
            aria-label={rec ? "録音中。押すと停止して送信します" : "押すと録音を開始します"}
            style={{ ...S.btnToggle, ...(rec ? S.toggleOn : busy ? S.toggleBusy : S.toggleIdle) }}>
      {rec
        ? <React.Fragment>
            <div style={{ fontSize: 44, lineHeight: 1 }}>■</div>
            <div style={{ fontSize: 40, fontWeight: 900, marginTop: 6, fontVariantNumeric: "tabular-nums" }}>{mmss(secs)}</div>
            <div style={{ fontSize: 16, fontWeight: 800, marginTop: 8 }}>録音中／もう一度押すと送信</div>
            {/* ★画面スリープを抑止できない端末では、消さないよう頼む（抑止できている時は何も出さない＝静かに効く） */}
            {wake === "off"
              ? <div style={{ fontSize: 13, fontWeight: 700, marginTop: 10, padding: "6px 12px", borderRadius: 8,
                              background: "rgba(0,0,0,.18)", lineHeight: 1.6 }}>
                  ⚠ 録音中は画面を消さないでください
                </div>
              : null}
          </React.Fragment>
        : busy
        ? <React.Fragment>
            <div style={{ fontSize: 44, lineHeight: 1 }}>⏳</div>
            <div style={{ fontSize: 20, fontWeight: 800, marginTop: 10 }}>{msg || "処理中…"}</div>
            <div style={{ fontSize: 14, fontWeight: 700, marginTop: 6 }}>そのままお待ちください</div>
          </React.Fragment>
        : <React.Fragment>
            <div style={{ fontSize: 52, lineHeight: 1 }}>🎤</div>
            <div style={{ fontSize: 27, fontWeight: 900, marginTop: 10 }}>{idleTitle}</div>
            <div style={{ fontSize: 15, fontWeight: 700, marginTop: 8 }}>{idleHint}</div>
          </React.Fragment>}
    </button>
  );
}

/* ───────── ログイン ───────── */
function Login({ onIn }) {
  const [id, setId] = useState("");
  const [pw, setPw] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  /* ★本番の栓：サーバが ENABLE_DEMO を off にしていたら「デモで入る」を出さない。
     （出しても 403 で入れないが、押せるボタンを置かない方が事故らない） */
  const [demoOk, setDemoOk] = useState(false);
  useEffect(() => {
    apiGET("/api/health").then((j) => setDemoOk(!!(j && j.enable_demo))).catch(() => setDemoOk(false));
  }, []);
  const go = (path, body) => {
    if (busy) return;
    setBusy(true); setErr("");
    apiJSON(path, "POST", body).then((res) => {
      setBusy(false);
      if (res && res.status === 200 && res.j && res.j.ok) { onIn(res.j.company); return; }
      setErr(path.indexOf("demo") < 0 ? "IDかパスワードが違います。"
             : (res && res.status === 403) ? "この環境ではデモログインは使えません。"
             : "デモ会社がありません（seed_dev.py を実行してください）。");
    }).catch(() => { setBusy(false); setErr("通信に失敗しました。"); });
  };
  return (
    <div style={{ ...S.page, maxWidth: 400, paddingTop: 56 }}>
      <div style={{ textAlign: "center", marginBottom: 24 }}>
        <div style={{ fontSize: 34, fontWeight: 900, letterSpacing: ".06em", color: "var(--accent)" }}>こえろぐ</div>
        <div style={{ ...S.sub, marginTop: 6 }}>接客の申し送りを、声で。AIが要点にまとめます。</div>
      </div>
      <div style={S.card}>
        <label style={S.label}>会社ID</label>
        <input style={S.input} value={id} autoCapitalize="none" autoCorrect="off"
               onChange={(e) => setId(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") go("/api/login", { id, pw }); }} />
        <label style={{ ...S.label, marginTop: 14 }}>パスワード</label>
        <input style={S.input} type="password" value={pw}
               onChange={(e) => setPw(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") go("/api/login", { id, pw }); }} />
        {err ? <div style={{ fontSize: 13, color: "var(--danger)", marginTop: 12 }}>{err}</div> : null}
        <button style={{ ...S.btn, marginTop: 16, opacity: busy ? .6 : 1 }} disabled={busy}
                onClick={() => go("/api/login", { id, pw })}>{busy ? "…" : "ログイン"}</button>
        {demoOk
          ? <button style={{ ...S.btnGhost, marginTop: 10 }} disabled={busy}
                    onClick={() => go("/api/demo-login", {})}>デモで入る</button>
          : null}
      </div>
    </div>
  );
}

/* ───────── 初期設定ウィザード（初回だけ・次から自動） ─────────
   Q1 あなたは？ → [店側スタッフ] / [キャスト]
     ・店側  → Q2 役職を選ぶ → Q3 名前を入力
     ・キャスト → 登録済みの源氏名から選ぶ or 源氏名を入力して新規登録
   ★結果は端末に記憶（koelog.device.v1 = {id, name, kind, role, memberId}）＝今日の録音者紐付けの拡張。
   ★同時に members にも本人を登録する（同名がいれば作らずその人を使う）＝店側管理者の事前登録と両立。
   ★役職ごとの権限や画面の出し分けは【まだやらない】。ここでは「記録する」だけ。 */
const STAFF_ROLES = ["社長", "部長", "店長", "フロアマネージャー", "キャッシャー", "スタッフ", "エスコート", "ボーイ"];
const KIND_LABEL = { staff: "店側スタッフ", cast: "キャスト" };

function SetupWizard({ members, device, onDone, onCancel }) {
  const [step, setStep] = useState("kind");        // kind → role → name ／ kind → cast
  const [kind, setKind] = useState("");
  const [role, setRole] = useState("");
  const [name, setName] = useState((device && device.name) || "");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  /* 端末に記憶しつつ、名簿(members)にも登録する。名簿側が落ちても端末の記憶は残す（録音は続けられる）。 */
  const finish = (theName, theKind, theRole) => {
    const t = (theName || "").trim().slice(0, 60);
    if (!t || busy) return;
    setBusy(true); setErr("");
    apiJSON("/api/members", "POST", { name: t, kind: theKind, role: theRole || null })
      .then((res) => {
        setBusy(false);
        const mid = (res && res.j && res.j.ok) ? res.j.id : null;
        const saved = saveDevice({ id: (device && device.id) || newDeviceId(),
                                   name: t, kind: theKind, role: theRole || null, memberId: mid });
        /* ★端末を台帳に載せる（記録だけ・承認待ちで入る）。失敗しても今までどおり使えるので待たない。 */
        apiJSON("/api/devices/register", "POST",
                { deviceId: saved.id, name: t, role: theRole || null, memberId: mid }).catch(() => {});
        onDone(saved);
      })
      .catch(() => {
        setBusy(false);
        onDone(saveDevice({ id: (device && device.id) || newDeviceId(),
                            name: t, kind: theKind, role: theRole || null, memberId: null }));
      });
  };

  const casts = members.filter((m) => m.kind === "cast" || !m.kind);   // 旧データ(kind未設定)も出す＝名簿から人が消えない
  const back = (to) => () => { setErr(""); setStep(to); };

  return (
    <div style={{ ...S.card, borderColor: "var(--accent)" }}>
      <div style={{ fontSize: 16, fontWeight: 900, color: "var(--accent)" }}>はじめの設定（1回だけ）</div>
      <div style={{ ...S.sub, marginTop: 6, lineHeight: 1.7 }}>
        この端末を誰が使うかを覚えます。次からは録音するだけで「録音者」が自動で付きます。
      </div>

      {/* Q1：区分 */}
      {step === "kind" ? (
        <div style={{ marginTop: 16 }}>
          <div style={{ fontSize: 15, fontWeight: 800, marginBottom: 10 }}>あなたは？</div>
          <button style={{ ...S.btn, marginBottom: 10 }}
                  onClick={() => { setKind("staff"); setStep("role"); }}>店側スタッフ</button>
          <button style={{ ...S.btn, background: "var(--accent-2)" }}
                  onClick={() => { setKind("cast"); setStep("cast"); }}>キャスト</button>
        </div>
      ) : null}

      {/* Q2（店側）：役職 */}
      {step === "role" ? (
        <div style={{ marginTop: 16 }}>
          <div style={{ fontSize: 15, fontWeight: 800, marginBottom: 10 }}>役職は？</div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
            {STAFF_ROLES.map((r) => (
              <button key={r} onClick={() => { setRole(r); setStep("name"); }}
                      style={{ ...S.btnGhost, width: "auto", padding: "11px 14px", fontSize: 14,
                               borderColor: role === r ? "var(--accent)" : "var(--border)" }}>{r}</button>
            ))}
          </div>
          <button style={{ ...S.btnGhost, marginTop: 14 }} onClick={back("kind")}>← 戻る</button>
        </div>
      ) : null}

      {/* Q3（店側）：名前 */}
      {step === "name" ? (
        <div style={{ marginTop: 16 }}>
          <div style={{ fontSize: 15, fontWeight: 800, marginBottom: 4 }}>お名前は？</div>
          <div style={{ ...S.sub, marginBottom: 10 }}>店側スタッフ・{role}</div>
          <input style={S.input} value={name} maxLength={60} placeholder="例）山田" autoFocus
                 onChange={(e) => setName(e.target.value)}
                 onKeyDown={(e) => { if (e.key === "Enter") finish(name, "staff", role); }} />
          <button style={{ ...S.btn, marginTop: 12, opacity: (busy || !name.trim()) ? .5 : 1 }}
                  disabled={busy || !name.trim()}
                  onClick={() => finish(name, "staff", role)}>{busy ? "…" : "この内容で始める"}</button>
          <button style={{ ...S.btnGhost, marginTop: 8 }} onClick={back("role")}>← 役職を選び直す</button>
        </div>
      ) : null}

      {/* Q2（キャスト）：源氏名を選ぶ or 新規登録 */}
      {step === "cast" ? (
        <div style={{ marginTop: 16 }}>
          <div style={{ fontSize: 15, fontWeight: 800, marginBottom: 10 }}>源氏名を選んでください</div>
          {casts.length ? (
            <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 14 }}>
              {casts.map((m) => (
                <button key={m.id} disabled={busy} onClick={() => finish(m.name, "cast", null)}
                        style={{ ...S.btnGhost, width: "auto", padding: "11px 14px", fontSize: 14 }}>{m.name}</button>
              ))}
            </div>
          ) : <div style={{ ...S.sub, marginBottom: 12 }}>まだ登録されたキャストがいません。下から登録してください。</div>}
          <label style={S.label}>一覧にない場合は源氏名を入力して新規登録</label>
          <input style={S.input} value={name} maxLength={60} placeholder="例）さくら"
                 onChange={(e) => setName(e.target.value)}
                 onKeyDown={(e) => { if (e.key === "Enter") finish(name, "cast", null); }} />
          <button style={{ ...S.btn, marginTop: 12, opacity: (busy || !name.trim()) ? .5 : 1 }}
                  disabled={busy || !name.trim()}
                  onClick={() => finish(name, "cast", null)}>{busy ? "…" : "この源氏名で登録して始める"}</button>
          <button style={{ ...S.btnGhost, marginTop: 8 }} onClick={back("kind")}>← 戻る</button>
        </div>
      ) : null}

      {err ? <div style={{ fontSize: 13, color: "var(--danger)", marginTop: 10 }}>{err}</div> : null}
      {onCancel ? <button style={{ ...S.btnGhost, marginTop: 10 }} onClick={onCancel}>やめる</button> : null}
    </div>
  );
}

/* 名簿への事前登録（店側管理者が本人不在でも登録できる導線）。ウィザードと同じ /api/members を使う。 */
function RosterAdd({ onAdded }) {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const [kind, setKind] = useState("cast");
  const [role, setRole] = useState("スタッフ");
  const [busy, setBusy] = useState(false);
  const add = () => {
    const t = (name || "").trim();
    if (!t || busy) return;
    setBusy(true);
    apiJSON("/api/members", "POST", { name: t, kind, role: kind === "staff" ? role : null })
      .then((res) => {
        setBusy(false);
        if (res && res.status === 200 && res.j && res.j.ok) { setName(""); onAdded && onAdded(); return; }
        alert("登録できませんでした（もう一度お試しください）。");
      })
      .catch(() => { setBusy(false); alert("通信に失敗しました。"); });
  };
  if (!open) {
    return <button style={{ ...S.btnGhost, marginTop: 10 }} onClick={() => setOpen(true)}>＋ スタッフ・キャストを名簿に登録する</button>;
  }
  return (
    <div style={{ marginTop: 10 }}>
      <label style={S.label}>区分</label>
      <div style={{ display: "flex", gap: 8 }}>
        {["cast", "staff"].map((k) => (
          <button key={k} onClick={() => setKind(k)}
                  style={{ ...S.btnGhost, padding: "10px 12px", fontSize: 13,
                           borderColor: kind === k ? "var(--accent)" : "var(--border)" }}>{KIND_LABEL[k]}</button>
        ))}
      </div>
      {kind === "staff" ? (
        <React.Fragment>
          <label style={{ ...S.label, marginTop: 12 }}>役職</label>
          <select value={role} onChange={(e) => setRole(e.target.value)} style={{ ...S.input, padding: "11px 8px" }}>
            {STAFF_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
          </select>
        </React.Fragment>
      ) : null}
      <label style={{ ...S.label, marginTop: 12 }}>{kind === "staff" ? "お名前" : "源氏名"}</label>
      <input style={S.input} value={name} maxLength={60}
             placeholder={kind === "staff" ? "例）山田" : "例）さくら"}
             onChange={(e) => setName(e.target.value)}
             onKeyDown={(e) => { if (e.key === "Enter") add(); }} />
      <button style={{ ...S.btn, marginTop: 10, opacity: (busy || !name.trim()) ? .5 : 1 }}
              disabled={busy || !name.trim()} onClick={add}>＋ 名簿に登録</button>
      <button style={{ ...S.btnGhost, marginTop: 8 }} onClick={() => { setOpen(false); setName(""); }}>閉じる</button>
    </div>
  );
}

/* ───────── キャスト履歴書（9項目・手入力フォーム）─────────
   源氏名は members.name を流用し、残り8項目＋顔写真を members.meta.profile に持つ。
   ★全項目 任意＝埋まった分だけ保存できる（必須で詰まらせない）。
   ★顔写真は Private バケット保存＝毎回サーバが作る署名付きURLでしか表示しない。
   ★誰が編集してよいか（役職マトリクス）は次段。今はログイン済みなら本人も店側も編集できる。 */
const BLOOD_TYPES = ["A", "B", "O", "AB", "不明"];
const todayYMD = () => {
  const d = new Date(), p = (n) => String(n).padStart(2, "0");
  return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate());
};
/* 生年月日から年齢（表示のときだけ計算する＝保存するのは生年月日） */
const ageOf = (ymd) => {
  const m = String(ymd || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!m) return null;
  const b = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])), now = new Date();
  if (isNaN(b.getTime())) return null;
  let a = now.getFullYear() - b.getFullYear();
  const before = now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate());
  if (before) a -= 1;
  return (a >= 0 && a < 120) ? a : null;
};

/* ★アップ前に縮小してJPEGへ。狙いは3つ：
     ・通信量とVisionのコストを下げる（履歴書の文字は長辺1600pxあれば十分読める）
     ・iPhoneのHEICをJPEGに変換する（OpenAI Visionは HEIC を受け付けない）
     ・失敗しても元のファイルをそのまま返す＝この処理で詰まらせない */
function shrinkImage(file, maxEdge, quality) {
  return new Promise((resolve) => {
    try {
      const url = URL.createObjectURL(file);
      const img = new Image();
      img.onload = () => {
        try {
          const scale = Math.min(1, maxEdge / Math.max(img.width, img.height));
          const w = Math.max(1, Math.round(img.width * scale));
          const h = Math.max(1, Math.round(img.height * scale));
          const cv = document.createElement("canvas");
          cv.width = w; cv.height = h;
          cv.getContext("2d").drawImage(img, 0, 0, w, h);
          cv.toBlob((b) => {
            URL.revokeObjectURL(url);
            resolve(b ? new File([b], "resume.jpg", { type: "image/jpeg" }) : file);
          }, "image/jpeg", quality);
        } catch (_e) { URL.revokeObjectURL(url); resolve(file); }
      };
      img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
      img.src = url;
    } catch (_e) { resolve(file); }
  });
}

/* ───────── 所属店の選択（キャストも店側スタッフも共通） ─────────
   ★保存は既存の POST /api/members 1本だけ。同じ名前の人が居ればその行を上書きするので名簿は増えない。
     kind / role は【今の値をそのまま送り返す】＝区分・役職・履歴書(meta)を欠落させない。
   ★この所属店が「声の10時を昼(10:00)と夜(22:00)どちらで読むか」の判定に使われる
     （所属店 → お店の昼/夜 の順に引く。未所属だと昼のまま）。 */
function StoreSelect({ member, stores, onSaved }) {
  const [busy, setBusy] = useState(false);
  const cur = member.store || "";
  const change = (e) => {
    const v = e.target.value;
    if (busy || v === cur) return;
    setBusy(true);
    apiJSON("/api/members", "POST", { name: member.name, kind: member.kind || null,
                                      role: member.role || null, store: v })
      .then((res) => {
        setBusy(false);
        if (res && res.status === 200 && res.j && res.j.ok) { onSaved && onSaved(v); return; }
        alert("所属店を保存できませんでした（もう一度お試しください）。");
      })
      .catch(() => { setBusy(false); alert("通信に失敗しました。"); });
  };
  return (
    <select value={cur} onChange={change} disabled={busy}
            title="このお店の昼/夜が、声の「10時」を10:00と22:00どちらで読むかに使われます"
            style={{ ...S.input, width: "auto", maxWidth: "100%", padding: "7px 8px", fontSize: 13,
                     fontWeight: 700, opacity: busy ? .5 : 1 }}>
      <option value="">未所属</option>
      {stores.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
    </select>
  );
}

function Profile({ member, onBack }) {
  const [data, setData] = useState(null);       // {id,name,kind,role,profile,photoUrl}
  const [f, setF] = useState(null);             // 編集中の9項目
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState("");
  const fileRef = useRef(null);
  const ocrRef = useRef(null);
  const [ocr, setOcr] = useState(null);       // {filled:[…], skipped:[…]} 直前のOCR結果の説明

  const [stores, setStores] = useState([]);     // 所属店の選択肢

  const load = () => apiGET("/api/members/" + member.id)
    .then((j) => { if (j && j.ok) { setData(j.member); setF({ ...j.member.profile }); } })
    .catch(() => {});
  useEffect(() => { load(); }, [member.id]);
  useEffect(() => { apiGET("/api/stores").then((j) => setStores((j && j.stores) || [])).catch(() => {}); }, []);

  const set = (k) => (e) => setF((c) => ({ ...c, [k]: e.target.value }));

  const save = () => {
    if (busy || !f) return;
    setBusy(true); setMsg("");
    apiJSON("/api/members/" + member.id + "/profile", "PUT", { profile: f })
      .then((res) => {
        setBusy(false);
        if (res && res.status === 200 && res.j && res.j.ok) {
          setData(res.j.member); setF({ ...res.j.member.profile }); setMsg("保存しました");
          setTimeout(() => setMsg(""), 2500); return;
        }
        alert("保存できませんでした（もう一度お試しください）。");
      })
      .catch(() => { setBusy(false); alert("通信に失敗しました。"); });
  };

  const pickPhoto = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file || busy) return;
    setBusy(true); setMsg("写真をアップロード中…");
    const fd = new FormData();
    fd.append("photo", file, file.name || "photo");
    fetch("/api/members/" + member.id + "/photo", { method: "POST", credentials: "same-origin", body: fd })
      .then((r) => r.json().catch(() => null).then((j) => ({ status: r.status, j })))
      .then((res) => {
        setBusy(false); setMsg("");
        if (res && res.status === 200 && res.j && res.j.ok) { setData(res.j.member); return; }
        const st = res && res.status;
        if (st === 400) alert("この形式の画像には対応していません（JPEG / PNG / WebP / HEIC）。");
        else if (st === 413) alert("写真が大きすぎます（6MBまで）。");
        else alert("写真を保存できませんでした。");
      })
      .catch(() => { setBusy(false); setMsg(""); alert("通信に失敗しました（写真）。"); });
    if (fileRef.current) fileRef.current.value = "";
  };

  /* ★履歴書の写真を読ませて、フォームの【空欄だけ】を埋める。
       既に入っている値は上書きしない＝人が直した内容をAIが壊さない（直したくなければ何もしなくていい）。 */
  const LABEL = { realName: "本名", birthday: "生年月日", bloodType: "血液型", hometown: "出身地",
                  hobby: "趣味・特技", joinedAt: "入店日", contact: "連絡先" };
  const runOcr = (e) => {
    const picked = e.target.files && e.target.files[0];
    if (ocrRef.current) ocrRef.current.value = "";
    if (!picked || busy) return;
    setBusy(true); setMsg("履歴書を読み取り中…（10〜20秒ほど）"); setOcr(null);
    shrinkImage(picked, 1600, 0.85).then((img) => {
      const fd = new FormData();
      fd.append("image", img, img.name || "resume.jpg");
      return fetch("/api/ocr/resume", { method: "POST", credentials: "same-origin", body: fd })
        .then((r) => r.json().catch(() => null).then((j) => ({ status: r.status, j })));
    }).then((res) => {
      setBusy(false); setMsg("");
      if (!(res && res.status === 200 && res.j && res.j.ok)) {
        const st = res && res.status;
        if (st === 400) alert("この画像は読めませんでした（JPEG / PNG / WebP でお試しください）。");
        else if (st === 413) alert("画像が大きすぎます。");
        else if (st === 503) alert("AIの設定が未完了です（OPENAI_API_KEY をご確認ください）。");
        else alert("読み取れませんでした。明るい場所で、まっすぐ写して撮り直してください。");
        return;
      }
      const got = res.j.profile || {};
      const filled = [], skipped = [];
      setF((cur) => {
        const next = { ...cur };
        Object.keys(LABEL).forEach((k) => {
          if (!got[k]) return;                       // 読めなかった項目は触らない
          if ((next[k] || "").trim()) { skipped.push(LABEL[k]); return; }   // 既入力は残す
          next[k] = got[k]; filled.push(LABEL[k]);
        });
        return next;
      });
      setOcr({ filled, skipped, stageName: got.stageName || "" });
    }).catch(() => { setBusy(false); setMsg(""); alert("通信に失敗しました（読み取り）。"); });
  };

  if (!data || !f) return <div style={{ ...S.page, ...S.sub, paddingTop: 40, textAlign: "center" }}>読み込み中…</div>;
  const age = ageOf(f.birthday);

  return (
    <div style={S.page}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14 }}>
        <button style={{ ...S.btnGhost, width: "auto", padding: "8px 12px", fontSize: 13 }} onClick={onBack}>← 戻る</button>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 17, fontWeight: 800 }}>{data.name} の履歴書</div>
          <div style={S.sub}>{data.kind === "cast" ? "キャスト" : data.kind === "staff" ? (data.role || "店側") : "区分未設定"}</div>
        </div>
        <RefreshButton />
      </div>

      {/* 顔写真＝Private保管・署名付きURLでのみ表示 */}
      <div style={S.card}>
        <label style={S.label}>顔写真</label>
        <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
          <div style={{ width: 96, height: 96, borderRadius: 12, overflow: "hidden", flexShrink: 0,
                        background: "var(--sunken)", border: "1px solid var(--border)",
                        display: "flex", alignItems: "center", justifyContent: "center" }}>
            {data.photoUrl
              ? <img src={data.photoUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
              : <span style={{ fontSize: 30, color: "var(--text-dimmer)" }}>👤</span>}
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <input ref={fileRef} type="file" accept="image/*" onChange={pickPhoto} style={{ display: "none" }} />
            <button style={{ ...S.btnGhost, opacity: busy ? .5 : 1 }} disabled={busy}
                    onClick={() => fileRef.current && fileRef.current.click()}>
              {data.photoUrl ? "写真を変える" : "＋ 写真を選ぶ"}
            </button>
            <div style={{ ...S.sub, marginTop: 8, lineHeight: 1.7 }}>
              店の外には出ません（非公開の保管庫に入り、見るたびに短時間だけ有効なURLで表示します）。
            </div>
          </div>
        </div>
      </div>

      {/* ★履歴書の紙から自動入力（半自動＝8割埋めて、人が残りと間違いを直す） */}
      <div style={{ ...S.card, marginTop: 14, borderColor: "var(--gold)" }}>
        <label style={S.label}>履歴書の紙から自動入力</label>
        <input ref={ocrRef} type="file" accept="image/*" capture="environment"
               onChange={runOcr} style={{ display: "none" }} />
        <button style={{ ...S.btn, background: "var(--gold)", color: "#fff", opacity: busy ? .5 : 1 }}
                disabled={busy} onClick={() => ocrRef.current && ocrRef.current.click()}>
          {busy ? (msg || "…") : "📄 履歴書を撮影 / 画像を選ぶ"}
        </button>
        <div style={{ ...S.sub, marginTop: 8, lineHeight: 1.8 }}>
          紙の履歴書を写すと、読めた項目だけ下のフォームに入ります。<b>空いている欄だけ</b>を埋めるので、
          手で直した内容は消えません。<b>この画像は保存しません</b>（読み取ったら捨てます）。
          読み間違いがあるので、保存する前に必ず目で確かめてください。
        </div>
        {ocr ? (
          <div style={{ marginTop: 10, padding: "10px 12px", borderRadius: 10, background: "var(--gold-soft)",
                        fontSize: 13, lineHeight: 1.8 }}>
            {ocr.filled.length ? <div>✅ 入れた項目：<b>{ocr.filled.join("・")}</b></div>
                               : <div>読み取れた項目がありませんでした。撮り直すか、手で入れてください。</div>}
            {ocr.skipped.length ? <div style={{ color: "var(--text-dim)" }}>既に入力済みのため触っていない項目：{ocr.skipped.join("・")}</div> : null}
            {ocr.stageName ? <div style={{ color: "var(--text-dim)" }}>紙に書かれていた源氏名：{ocr.stageName}（名簿の登録名が優先です）</div> : null}
          </div>
        ) : null}
      </div>

      <div style={{ ...S.card, marginTop: 14 }}>
        <label style={S.label}>源氏名</label>
        <input style={{ ...S.input, background: "var(--sunken)" }} value={data.name} readOnly />
        <div style={{ ...S.sub, marginTop: 6 }}>源氏名は名簿の登録名です（変えるときは名簿から）。</div>

        {/* ★所属店＝声の時刻の読み方（昼10:00／夜22:00）にも使う。キャストも店側スタッフもここで設定できる。 */}
        <label style={{ ...S.label, marginTop: 16 }}>所属店</label>
        <StoreSelect member={data} stores={stores}
                     onSaved={(v) => setData((c) => ({ ...c, store: v || null }))} />
        <div style={{ ...S.sub, marginTop: 6 }}>
          {stores.length
            ? "選ぶとすぐ保存されます。夜営業のお店にしておくと、声の「10時」を22時として読みます。"
            : "お店がまだ登録されていません（「誰をどの店に」の画面で登録できます）。"}
        </div>

        <label style={{ ...S.label, marginTop: 16 }}>本名</label>
        <input style={S.input} value={f.realName} maxLength={60} placeholder="例）山田花子" onChange={set("realName")} />

        <label style={{ ...S.label, marginTop: 16 }}>
          生年月日{age !== null ? <span style={{ color: "var(--accent)" }}>（{age}歳）</span> : null}
        </label>
        <input style={S.input} type="date" value={f.birthday} onChange={set("birthday")} />

        <label style={{ ...S.label, marginTop: 16 }}>血液型</label>
        <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
          {BLOOD_TYPES.map((b) => (
            <button key={b} onClick={() => setF((c) => ({ ...c, bloodType: c.bloodType === b ? "" : b }))}
                    style={{ ...S.btnGhost, width: "auto", padding: "10px 16px", fontSize: 14,
                             borderColor: f.bloodType === b ? "var(--accent)" : "var(--border)",
                             color: f.bloodType === b ? "var(--accent)" : "var(--text-2)" }}>{b}</button>
          ))}
        </div>

        <label style={{ ...S.label, marginTop: 16 }}>出身地</label>
        <input style={S.input} value={f.hometown} maxLength={60} placeholder="例）北海道札幌市" onChange={set("hometown")} />

        <label style={{ ...S.label, marginTop: 16 }}>趣味・特技</label>
        <textarea rows={3} maxLength={300} value={f.hobby} placeholder="例）ピアノ／カラオケ／お酒に強い"
                  style={{ ...S.input, fontSize: 15, resize: "vertical" }} onChange={set("hobby")} />

        <label style={{ ...S.label, marginTop: 16 }}>入店日</label>
        <div style={{ display: "flex", gap: 8 }}>
          <input style={{ ...S.input, flex: 1 }} type="date" value={f.joinedAt} onChange={set("joinedAt")} />
          <button style={{ ...S.btnGhost, width: "auto", padding: "12px 16px", fontSize: 13 }}
                  onClick={() => setF((c) => ({ ...c, joinedAt: todayYMD() }))}>今日</button>
        </div>

        <label style={{ ...S.label, marginTop: 16 }}>連絡先</label>
        <input style={S.input} value={f.contact} maxLength={120} placeholder="例）090-0000-0000 / LINE ID" onChange={set("contact")} />

        <div style={{ ...S.sub, marginTop: 14, lineHeight: 1.7 }}>
          すべて任意です。分かるところだけ入れて保存できます。
        </div>
        <button style={{ ...S.btn, marginTop: 12, opacity: busy ? .5 : 1 }} disabled={busy}
                onClick={save}>{busy ? "…" : "保存する"}</button>
        {msg ? <div style={{ fontSize: 13, color: "var(--ok)", marginTop: 10, textAlign: "center" }}>{msg}</div> : null}
        {data.profile.updatedAt
          ? <div style={{ ...S.sub, marginTop: 8, textAlign: "center" }}>最終更新 {fmtAt(data.profile.updatedAt)}</div>
          : null}
      </div>
    </div>
  );
}

/* ───────── D：役職×役職 可視マトリクス（編集画面）─────────
   縦＝送り手（誰が出した申し送りか）／横＝受け手（誰が見るか）。マスを押すとON/OFF。
   ★保存されるのは「既定と違うマスだけ」＝設定が無い会社は賢いデフォルトで動く。
   ★所有ベース（自分が録った申し送り）はこの表に関わらず常に見える。 */
const VIS_LABEL = (k) => (k === "cast" ? "キャスト" : k);
/* ★上層部＝重い操作（カルテ削除・Google連携解除・端末承認）を任せる役職。backend の _VIS_TOP と同じ並び。
   ボタンの出し分けはここで行うが、本丸はサーバ側の403（画面を隠すだけの防御にしない）。 */
const TOP_ROLES = ["社長", "部長", "店長"];
const isTopDevice = (d) => !!d && d.kind === "staff" && TOP_ROLES.indexOf(d.role || "") >= 0;

const VIS_SHORT = { "社長": "社長", "部長": "部長", "店長": "店長", "フロアマネージャー": "FM",
                    "キャッシャー": "会計", "スタッフ": "スタ", "エスコート": "エス", "ボーイ": "ボイ",
                    "cast": "キャ" };

function Visibility({ onBack }) {
  const [d, setD] = useState(null);        // {senders,viewers,matrix,defaults,overrides}
  const [m, setM] = useState(null);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState("");

  useEffect(() => {
    /* ★監査L-3：読み取りも staff 限定になったので、保存(PUT)と同じ withViewer で「見る人」を渡す
       （端末台帳に載っていない端末でも vid/vby で名簿の本人に解決できる＝編集画面が開けなくならない）。 */
    apiGET(withViewer("/api/visibility")).then((j) => { if (j && j.ok) { setD(j); setM(j.matrix); } }).catch(() => {});
  }, []);

  const toggle = (snd, vwr) => setM((c) => ({ ...c, [snd]: { ...c[snd], [vwr]: !c[snd][vwr] } }));
  const save = () => {
    if (busy || !m) return;
    setBusy(true); setMsg("");
    apiJSON(withViewer("/api/visibility"), "PUT", { matrix: m })
      .then((res) => {
        setBusy(false);
        if (res && res.status === 200 && res.j && res.j.ok) {
          setM(res.j.matrix); setD((c) => ({ ...c, overrides: res.j.overrides }));
          setMsg("保存しました"); setTimeout(() => setMsg(""), 2500); return;
        }
        if (res && res.status === 403) { alert("この設定を変えられるのは店側スタッフだけです。"); return; }
        alert("保存できませんでした。");
      })
      .catch(() => { setBusy(false); alert("通信に失敗しました。"); });
  };
  const reset = () => { if (d) setM(JSON.parse(JSON.stringify(d.defaults))); };

  if (!d || !m) return <div style={{ ...S.page, ...S.sub, paddingTop: 40, textAlign: "center" }}>読み込み中…</div>;
  const changed = (snd, vwr) => m[snd][vwr] !== d.defaults[snd][vwr];
  const nOverride = Object.keys(d.overrides || {}).reduce((n, k) => n + Object.keys(d.overrides[k]).length, 0);

  return (
    <div style={S.page}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14 }}>
        <button style={{ ...S.btnGhost, width: "auto", padding: "8px 12px", fontSize: 13 }} onClick={onBack}>← 戻る</button>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 17, fontWeight: 800 }}>誰の申し送りが誰に見えるか</div>
          <div style={S.sub}>縦＝出した人／横＝見る人</div>
        </div>
        <RefreshButton />
      </div>

      <div style={{ ...S.card, marginBottom: 12 }}>
        <div style={{ fontSize: 13, lineHeight: 1.9 }}>
          マスを押すと切り替わります（<b style={{ color: "var(--ok)" }}>✓</b>＝見える／—＝見えない）。
          <br />★<b>自分が録った申し送りは、この表に関わらず必ず本人に見えます。</b>
          <br />★見せる量を減らす仕組みです。端末で名乗った役職で判定するので、鍵の代わりにはなりません。
        </div>
      </div>

      <div style={{ ...S.card, overflowX: "auto" }}>
        <table style={{ borderCollapse: "collapse", fontSize: 12, minWidth: 520 }}>
          <thead>
            <tr>
              <th style={{ padding: "6px 8px", textAlign: "left", color: "var(--text-dim)", fontWeight: 700,
                           position: "sticky", left: 0, background: "var(--surface)" }}>出した人 ＼ 見る人</th>
              {d.viewers.map((v) => (
                <th key={v} style={{ padding: "6px 4px", color: "var(--text-dim)", fontWeight: 700, minWidth: 40 }}>
                  {VIS_SHORT[v] || VIS_LABEL(v)}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {d.senders.map((sn) => (
              <tr key={sn}>
                <td style={{ padding: "6px 8px", whiteSpace: "nowrap", fontWeight: 700,
                             position: "sticky", left: 0, background: "var(--surface)",
                             borderTop: "1px solid var(--divider)" }}>{VIS_LABEL(sn)}</td>
                {d.viewers.map((v) => (
                  <td key={v} style={{ padding: 2, textAlign: "center", borderTop: "1px solid var(--divider)" }}>
                    <button onClick={() => toggle(sn, v)}
                            title={VIS_LABEL(sn) + " の申し送りを " + VIS_LABEL(v) + " が見る"}
                            style={{ width: 34, height: 30, borderRadius: 7, cursor: "pointer", fontWeight: 800,
                                     border: changed(sn, v) ? "2px solid var(--gold)" : "1px solid var(--border)",
                                     background: m[sn][v] ? "var(--ok-soft)" : "var(--sunken)",
                                     color: m[sn][v] ? "var(--ok)" : "var(--text-dimmer)" }}>
                      {m[sn][v] ? "✓" : "—"}
                    </button>
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div style={{ ...S.card, marginTop: 12 }}>
        <div style={{ ...S.sub, marginBottom: 10 }}>金色の枠＝既定から変えたマス（{nOverride}箇所 保存済み）</div>
        <button style={{ ...S.btn, opacity: busy ? .5 : 1 }} disabled={busy} onClick={save}>{busy ? "…" : "保存する"}</button>
        <button style={{ ...S.btnGhost, marginTop: 8 }} onClick={reset}>おすすめの初期設定に戻す（保存はまだ）</button>
        {msg ? <div style={{ fontSize: 13, color: "var(--ok)", marginTop: 10, textAlign: "center" }}>{msg}</div> : null}
      </div>
    </div>
  );
}

/* ───────── 配置最適化（人物像 → 向いている店）─────────
   溜まった申し送りから1人ずつ人物像を見立て、店ごとの向き度を出す。
   ★AIは材料を出すだけ＝決めるのは人。申し送りが薄い人は「まだ分からない」と正直に出す。 */
const STORE_MAX = 12;    // ★backend の _STORE_MAX と同じ上限（超えた分はサーバが捨てるので画面側でも止める）
const LEVEL_LABEL = { thick: "情報たっぷり", some: "ある程度", thin: "まだ薄い" };
const LEVEL_TONE = { thick: "var(--ok)", some: "var(--gold)", thin: "var(--text-dimmer)" };

function Placement({ onBack }) {
  const [stores, setStores] = useState([]);
  const [casts, setCasts] = useState(null);
  const [pers, setPers] = useState({});      // memberId -> persona
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(0);
  const [saving, setSaving] = useState(false);
  const [addOpen, setAddOpen] = useState(false);
  const [newName, setNewName] = useState("");
  const [newVibe, setNewVibe] = useState("");
  const [terms, setTerms] = useState([]);          // ★よく使う言葉（銘柄など）
  const [newTerm, setNewTerm] = useState("");

  /* ★用語も店と同じ流儀＝作り直した一覧を丸ごとPUT。失敗したら元に戻す。 */
  const putTerms = (next) => {
    if (saving) return;
    const prev = terms;
    setTerms(next); setSaving(true);
    apiJSON("/api/terms", "PUT", { terms: next })
      .then(({ j }) => {
        if (!j || !j.ok) { setTerms(prev); alert("保存できませんでした（もう一度お試しください）。"); return; }
        setTerms(j.terms || next);
      })
      .catch(() => { setTerms(prev); alert("通信に失敗しました。"); })
      .then(() => setSaving(false));
  };
  /* カンマ・読点・改行のどれで区切ってもまとめて足せる（1語ずつ打たせない）。 */
  const addTerms = () => {
    const add = (newTerm || "").split(/[,、\n]/).map((x) => x.trim()).filter(Boolean);
    if (!add.length || saving) return;
    const next = terms.slice();
    add.forEach((t) => { if (next.indexOf(t) < 0) next.push(t); });
    if (next.length === terms.length) { setNewTerm(""); return; }
    putTerms(next); setNewTerm("");
  };
  const removeTerm = (t) => putTerms(terms.filter((x) => x !== t));

  /* ★店の保存は【必ずここ1本】を通す。PUT /api/stores は一覧まるごと差し替えの既存APIなので、
     追加も削除も昼/夜も「作り直した一覧を丸ごと送る」で揃う＝経路が増えない。
     失敗したら送る前の一覧に戻す＝画面と保存内容がズレたままにしない。 */
  const putStores = (next) => {
    if (saving) return;
    const prev = stores;
    setStores(next); setSaving(true);
    apiJSON("/api/stores", "PUT", { stores: next })
      .then(({ j }) => {
        if (!j || !j.ok) { setStores(prev); alert("保存できませんでした（もう一度お試しください）。"); return; }
        setStores(j.stores || next);
      })
      .catch(() => { setStores(prev); alert("通信に失敗しました。"); })
      .then(() => setSaving(false));
  };

  /* ★昼/夜＝声の「10時」を昼(10:00)と夜(22:00)どちらで読むか。既定は昼。 */
  const toggleDayNight = (name) => putStores(stores.map((s) => (s.name === name
    ? { ...s, day_night: s.day_night === "night" ? "day" : "night" } : s)));

  /* ★追加＝今の一覧の後ろに足すだけ（既存店は触らない）。同じ名前は作らせない。 */
  const addStore = () => {
    const t = (newName || "").trim();
    if (!t || saving) return;
    if (stores.length >= STORE_MAX) return;
    if (stores.some((s) => s.name === t)) { alert("同じ名前のお店がすでにあります。"); return; }
    putStores(stores.concat([{ name: t, vibe: (newVibe || "").trim(), day_night: "day" }]));
    setNewName(""); setNewVibe(""); setAddOpen(false);
  };

  /* ★削除＝その店だけ抜いた一覧を送る。誤爆防止に1段確認を挟む。 */
  const removeStore = (name) => {
    if (saving) return;
    if (!window.confirm("「" + name + "」を削除しますか？\n（このお店の設定だけが消えます。申し送り・カルテは消えません）")) return;
    putStores(stores.filter((s) => s.name !== name));
  };

  useEffect(() => {
    apiGET("/api/stores").then((j) => setStores((j && j.stores) || [])).catch(() => {});
    apiGET("/api/terms").then((j) => setTerms((j && j.terms) || [])).catch(() => {});
    apiGET("/api/members?kind=cast")
      .then((j) => setCasts(((j && j.members) || []).filter((m) => m.kind === "cast")))
      .catch(() => setCasts([]));
  }, []);

  /* ★1人ずつ順に見立てる（同時に投げない＝APIを叩き過ぎない・進み具合が見える） */
  const run = () => {
    if (busy || !casts) return;
    setBusy(true); setDone(0);
    const list = casts.slice();
    const step = (i) => {
      if (i >= list.length) { setBusy(false); return; }
      /* ★見る人を必ず名乗る＝サーバが名簿で裏を取り、その人に見えてよい申し送りだけを材料にする */
      apiGET(withViewer("/api/casts/" + list[i].id + "/persona"))
        .then((j) => { if (j && j.ok) setPers((c) => ({ ...c, [list[i].id]: j.persona })); })
        .catch(() => {})
        .then(() => { setDone(i + 1); step(i + 1); });
    };
    step(0);
  };

  if (casts === null) return <div style={{ ...S.page, ...S.sub, paddingTop: 40, textAlign: "center" }}>読み込み中…</div>;

  return (
    <div style={S.page}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14 }}>
        <button style={{ ...S.btnGhost, width: "auto", padding: "8px 12px", fontSize: 13 }} onClick={onBack}>← 戻る</button>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 17, fontWeight: 800 }}>誰をどの店に</div>
          <div style={S.sub}>申し送りから人物像を見立て、店ごとの向き度を出します</div>
        </div>
        <RefreshButton />
      </div>

      <div style={{ ...S.card, marginBottom: 12 }}>
        {/* ★お店の一覧＝ここが店の登録場所も兼ねる（新しい画面は作らない）。
            1行＝店名／昼夜チップ／削除。iPhoneでも折り返して収まるよう flex+wrap で組む。 */}
        <div style={{ fontSize: 13, lineHeight: 1.9 }}>
          <div style={{ fontWeight: 800 }}>お店</div>
          {stores.length
            ? stores.map((s) => (
                <div key={s.name} style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap",
                                           padding: "6px 0", borderTop: "1px solid var(--border)" }}>
                  <b style={{ flex: 1, minWidth: 0, wordBreak: "break-word" }}>{s.name}</b>
                  {/* ★昼/夜＝声の「10時」を何時と読むかだけの設定（夜の店は22:00と読む）。 */}
                  <span onClick={() => toggleDayNight(s.name)} title="声の「10時」を昼(10:00)/夜(22:00)どちらで読むか"
                        style={{ ...(s.day_night === "night" ? S.chip : S.chipDim),
                                 padding: "6px 14px", cursor: saving ? "default" : "pointer", opacity: saving ? .5 : 1 }}>
                    {s.day_night === "night" ? "夜" : "昼"}
                  </span>
                  <span onClick={() => removeStore(s.name)} title="このお店を削除する"
                        style={{ ...S.chipDim, padding: "6px 12px", color: "var(--danger)",
                                 cursor: saving ? "default" : "pointer", opacity: saving ? .5 : 1 }}>✕</span>
                </div>
              ))
            : <div style={{ color: "var(--danger)" }}>お店が登録されていません。</div>}

          {addOpen
            ? <div style={{ marginTop: 10 }}>
                <label style={S.label}>お店の名前</label>
                <input style={S.input} value={newName} maxLength={60} placeholder="例）CLUB Lumière"
                       onChange={(e) => setNewName(e.target.value)}
                       onKeyDown={(e) => { if (e.key === "Enter") addStore(); }} />
                <label style={{ ...S.label, marginTop: 12 }}>お店の雰囲気（任意）</label>
                <input style={S.input} value={newVibe} maxLength={200} placeholder="例）夜営業・落ち着いた大人の店"
                       onChange={(e) => setNewVibe(e.target.value)}
                       onKeyDown={(e) => { if (e.key === "Enter") addStore(); }} />
                <div style={{ ...S.sub, marginTop: 6 }}>雰囲気の文は、配置の見立ての材料になります。あとで昼/夜も切り替えられます。</div>
                <button style={{ ...S.btn, marginTop: 10, opacity: (saving || !newName.trim()) ? .5 : 1 }}
                        disabled={saving || !newName.trim()} onClick={addStore}>登録</button>
                <button style={{ ...S.btnGhost, marginTop: 8 }}
                        onClick={() => { setAddOpen(false); setNewName(""); setNewVibe(""); }}>やめる</button>
              </div>
            : stores.length >= STORE_MAX
            ? <div style={{ ...S.sub, marginTop: 10 }}>これ以上追加できません（最大{STORE_MAX}店）</div>
            : <button style={{ ...S.btnGhost, marginTop: 10 }} onClick={() => setAddOpen(true)}>＋ 店を追加</button>}

          {/* ★聞き取りの手がかり＝お酒の銘柄など、一般の言葉に化けやすい固有名詞をここに登録する。
              登録した語は文字起こしに「こういう言葉が出ます」と伝わるので、化けにくくなる。 */}
          <div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid var(--border)" }}>
            <div style={{ fontWeight: 800 }}>よく使う言葉（お酒の銘柄など）</div>
            <div style={{ ...S.sub, marginTop: 4, lineHeight: 1.7 }}>
              声が「キッチョム」を「キッチン」のように聞き間違えるのを防ぎます。源氏名とお客様のお名前は
              名簿から自動で使うので、ここには<b>銘柄や独自の言い回し</b>だけ入れてください。
            </div>
            {terms.length ? (
              <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
                {terms.map((t) => (
                  <span key={t} style={{ ...S.chipDim, padding: "6px 10px" }}>
                    {t}
                    <span onClick={() => removeTerm(t)} title="この言葉を削除する"
                          style={{ marginLeft: 8, color: "var(--danger)", fontWeight: 900,
                                   cursor: saving ? "default" : "pointer", opacity: saving ? .5 : 1 }}>✕</span>
                  </span>
                ))}
              </div>
            ) : <div style={{ ...S.sub, marginTop: 8 }}>まだ登録がありません。</div>}
            <input style={{ ...S.input, marginTop: 10 }} value={newTerm} maxLength={40}
                   placeholder="例）キッチョム（カンマ区切りでまとめて入力できます）"
                   onChange={(e) => setNewTerm(e.target.value)}
                   onKeyDown={(e) => { if (e.key === "Enter") addTerms(); }} />
            <button style={{ ...S.btnGhost, marginTop: 8, opacity: (saving || !newTerm.trim()) ? .5 : 1 }}
                    disabled={saving || !newTerm.trim()} onClick={addTerms}>＋ 言葉を追加</button>
          </div>

          <div style={{ marginTop: 10 }}>★これは<b>相談相手</b>です。申し送りに書かれたことだけを材料にしています。決めるのは人です。</div>
        </div>
        <button style={{ ...S.btn, marginTop: 12, opacity: busy ? .5 : 1 }} disabled={busy || !casts.length}
                onClick={run}>
          {busy ? "見立て中… " + done + " / " + casts.length + "人" : "🔎 全員を見立てる（" + casts.length + "人）"}
        </button>
      </div>

      <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        {casts.map((m) => {
          const p = pers[m.id];
          const top = p && p.fit && p.fit.length ? p.fit[0] : null;
          const moved = top && m.store && top.store !== m.store;
          return (
            <div key={m.id} style={S.card}>
              <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                <span style={{ fontSize: 17, fontWeight: 900 }}>{m.name}</span>
                {/* ★「今：〈店名〉」の表示だけだった所を、その場で所属店を変えられるようにした（新画面なし）。 */}
                <span style={{ ...S.sub, marginTop: 0 }}>今：</span>
                <StoreSelect member={m} stores={stores}
                             onSaved={(v) => setCasts((cs) => cs.map((c) => (c.id === m.id ? { ...c, store: v || null } : c)))} />
                {p ? <span style={{ ...S.chipDim, color: LEVEL_TONE[p.level] }}>
                       {LEVEL_LABEL[p.level]}・申し送り{p.count}本</span> : null}
              </div>

              {!p ? <div style={{ ...S.sub, marginTop: 8 }}>上のボタンで見立てます。</div> : (
                <React.Fragment>
                  {p.summary ? <div style={{ fontSize: 14, marginTop: 10, lineHeight: 1.8 }}>{p.summary}</div> : null}
                  {p.note ? <div style={{ ...S.sub, marginTop: 8 }}>{p.note}</div> : null}
                  {(p.tags || []).length ? (
                    <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
                      {p.tags.map((t) => <span key={t} style={S.chip}>{t}</span>)}
                    </div>
                  ) : null}

                  {(p.fit || []).length ? (
                    <div style={{ marginTop: 12 }}>
                      {p.fit.map((f, i) => (
                        <div key={f.store} style={{ marginTop: i ? 8 : 0 }}>
                          <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
                            <span style={{ width: 118, fontWeight: i === 0 ? 800 : 600,
                                           color: i === 0 ? "var(--accent)" : "var(--text-2)" }}>{f.store}</span>
                            <div style={{ flex: 1, height: 10, borderRadius: 6, background: "var(--sunken)", overflow: "hidden" }}>
                              <div style={{ width: f.score + "%", height: "100%",
                                            background: i === 0 ? "var(--accent)" : "var(--text-dimmer)" }} />
                            </div>
                            <span style={{ width: 34, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{f.score}</span>
                          </div>
                          <div style={{ ...S.sub, marginLeft: 118, marginTop: 2, lineHeight: 1.6 }}>{f.why}</div>
                        </div>
                      ))}
                      {top ? (
                        <div style={{ marginTop: 12, padding: "10px 12px", borderRadius: 10,
                                      background: moved ? "var(--gold-soft)" : "var(--ok-soft)",
                                      border: "1px solid " + (moved ? "var(--gold)" : "var(--ok)"),
                                      fontSize: 14, fontWeight: 700 }}>
                          {p.unknown ? "まだ判断材料が足りません（申し送りが溜まると見立てが変わります）"
                            : moved ? "→ " + top.store + " に移すと活きるかもしれません（今は " + m.store + "）"
                            : "→ " + top.store + " が向いています" + (m.store ? "（今のままでOK）" : "")}
                        </div>
                      ) : null}
                    </div>
                  ) : null}
                </React.Fragment>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ───────── Googleカレンダー連携（段1＝繋ぐところまで。予定の書き込みは段2）─────────
   ★リフレッシュトークンはサーバの会社設定に入り、画面には一切降りてこない（出るのは繋いだメールだけ）。 */
const GOOGLE_MSG = {
  ok: { tone: "ok", text: "Googleカレンダーと連携しました。" },
  denied: { tone: "warn", text: "Googleの画面でキャンセルされました。" },
  bad_state: { tone: "warn", text: "連携をやり直してください（画面を開き直してから押してください）。" },
  no_code: { tone: "warn", text: "連携に失敗しました。もう一度お試しください。" },
  need_login: { tone: "warn", text: "先にこえろぐにログインしてから連携してください。" },
  not_configured: { tone: "warn", text: "Google連携の設定がまだです（管理者にご確認ください）。" },
  exchange_failed: { tone: "warn", text: "Googleとのやり取りに失敗しました。時間をおいてお試しください。" },
  no_refresh_token: { tone: "warn", text: "連携情報を受け取れませんでした。Googleのアカウント設定でこえろぐのアクセスを一度解除してから、もう一度お試しください。" },
};

function GoogleCard() {
  const [g, setG] = useState(null);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState(null);

  const load = () => apiGET("/api/google/status").then((j) => { if (j && j.ok) setG(j.google); }).catch(() => {});
  useEffect(() => {
    load();
    /* コールバックから ?google=... で戻ってくる。読んだらURLからは消す（再読込で再表示しない）。 */
    try {
      const p = new URLSearchParams(window.location.search);
      const v = p.get("google");
      if (v) {
        setMsg(GOOGLE_MSG[v] || { tone: "warn", text: "連携に失敗しました。" });
        p.delete("google");
        const q = p.toString();
        window.history.replaceState({}, "", window.location.pathname + (q ? "?" + q : ""));
      }
    } catch (_e) {}
  }, []);

  const disconnect = () => {
    if (busy) return;
    if (!window.confirm("Googleカレンダーとの連携を解除します。よろしいですか？")) return;
    setBusy(true);
    apiJSON(withViewer("/api/google/disconnect"), "POST", {})
      .then((res) => { setBusy(false); if (res && res.j && res.j.ok) { setG(res.j.google); setMsg(null); } })
      .catch(() => { setBusy(false); alert("通信に失敗しました。"); });
  };

  if (!g) return null;
  return (
    <div style={{ ...S.card, marginTop: 12 }}>
      <label style={S.label}>Googleカレンダー連携</label>
      {msg ? (
        <div style={{ fontSize: 13, lineHeight: 1.8, marginBottom: 10, padding: "8px 10px", borderRadius: 8,
                      background: msg.tone === "ok" ? "var(--ok-soft)" : "var(--gold-soft)",
                      color: msg.tone === "ok" ? "var(--ok)" : "var(--text)" }}>{msg.text}</div>
      ) : null}
      {g.needsReconnect ? (
        <div style={{ fontSize: 13, fontWeight: 800, lineHeight: 1.8, marginBottom: 10, padding: "8px 10px",
                      borderRadius: 8, background: "var(--danger-soft)", color: "var(--danger)",
                      border: "1px solid var(--danger)" }}>
          ⚠ Google連携が切れています（{g.revokedAt ? fmtAt(g.revokedAt) : "時期不明"}）。
          <br />このままでは声から拾った予定がカレンダーに入りません。下のボタンで再連携してください。
        </div>
      ) : null}
      {!g.configured ? (
        <div style={S.sub}>この環境ではまだ設定されていません（管理者の設定待ち）。</div>
      ) : g.connected ? (
        <React.Fragment>
          <div style={{ fontSize: 14, lineHeight: 1.8 }}>
            <span style={{ ...S.chipOk, marginRight: 8 }}>連携済み</span>
            <b>{g.email || "（アカウント不明）"}</b>
          </div>
          {g.connectedAt ? <div style={{ ...S.sub, marginTop: 6 }}>連携日時 {fmtAt(g.connectedAt)}</div> : null}
          <div style={{ ...S.sub, marginTop: 8, lineHeight: 1.7 }}>
            ※ 声から拾った予定は、このGoogleカレンダーに自動で入ります（時刻が分かれば時刻付き、分からなければ終日）。
          </div>
          {isTopDevice(loadDevice())
            ? <button style={{ ...S.btnGhost, marginTop: 10, opacity: busy ? .5 : 1 }} disabled={busy}
                      onClick={disconnect}>連携を解除する</button>
            : <div style={{ ...S.sub, marginTop: 10 }}>連携の解除は店長以上のみです。</div>}
        </React.Fragment>
      ) : (
        <React.Fragment>
          <div style={{ ...S.sub, marginBottom: 10, lineHeight: 1.7 }}>
            繋ぐと、声から拾った予定がGoogleカレンダーへ自動で入るようになります。
          </div>
          <button style={{ ...S.btnGhost, borderColor: "var(--accent)", color: "var(--accent)" }}
                  onClick={() => { window.location.href = "/api/google/connect"; }}>
            📅 Googleカレンダーと連携する
          </button>
        </React.Fragment>
      )}
    </div>
  );
}

/* ───────── ホーム＝本丸。お客様を選ばずに録る → 自動で仕分かれる ───────── */
const ROUTE_VIEW = {
  matched:   { chip: "自動で振り分け", tone: "ok",   lead: "のカルテに入れました" },
  new:       { chip: "新規カルテ",     tone: "ok",   lead: "のカルテを新しく作りました" },
  ambiguous: { chip: "未分類",         tone: "warn", lead: "" },
  unsorted:  { chip: "未分類",         tone: "warn", lead: "" },
};

/* ───────── 端末の台帳（第1段：記録と承認まで・締め出しはまだ入れていない）─────────
   ★上層部（社長/部長/店長）の端末にだけ出す。サーバ側も同じ判定をして、それ以外には中身を返さない。
   ★ここで「承認する／使えなくする」を押しても、今の段階では利用は止まらない（印が変わるだけ）。 */
function DeviceApproval() {
  const [d] = useState(loadDevice);
  const [data, setData] = useState(null);      // {allowed, pending[], approved[]}
  const [busy, setBusy] = useState(false);
  const [open, setOpen] = useState(false);

  const load = () => apiGET(withViewer("/api/devices"))
    .then((j) => setData(j && j.ok ? j : null)).catch(() => {});
  useEffect(() => { load(); }, []);

  const act = (id, how) => {
    if (busy) return;
    setBusy(true);
    apiJSON("/api/devices/" + id + "/" + how, "POST",
            { by: (d && d.name) || "", role: (d && d.role) || "", deviceId: (d && d.id) || "" })
      .then((res) => {
        setBusy(false);
        if (res && res.status === 200 && res.j && res.j.ok) { load(); return; }
        alert(res && res.status === 403 ? "この端末には承認の権限がありません。" : "変更できませんでした。");
      })
      .catch(() => { setBusy(false); alert("通信に失敗しました。"); });
  };

  if (!data || !data.allowed) return null;     // ★上層部以外には何も出さない
  const pend = data.pending || [], appr = data.approved || [];
  const row = (x, actions) => (
    <div key={x.id} style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap",
                             padding: "8px 0", borderTop: "1px solid var(--border)" }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontWeight: 800, wordBreak: "break-word" }}>
          {x.name || "（名前なし）"}{x.role ? "・" + x.role : ""}
        </div>
        <div style={S.sub}>最初に使った日時 {x.requestedAt ? fmtAt(x.requestedAt) : "不明"}</div>
      </div>
      {actions}
    </div>
  );
  return (
    <div style={{ ...S.card, marginTop: 12 }}>
      <label style={S.label}>承認待ちの端末</label>
      {pend.length
        ? pend.map((x) => row(x, (
            <React.Fragment>
              <span onClick={() => act(x.id, "approve")}
                    style={{ ...S.chipOk, padding: "7px 12px", cursor: busy ? "default" : "pointer",
                             opacity: busy ? .5 : 1 }}>承認する</span>
              <span onClick={() => act(x.id, "reject")}
                    style={{ ...S.chipDim, padding: "7px 12px", color: "var(--danger)",
                             cursor: busy ? "default" : "pointer", opacity: busy ? .5 : 1 }}>拒否する</span>
            </React.Fragment>
          )))
        : <div style={S.sub}>承認待ちはありません。</div>}

      <button style={{ ...S.btnGhost, marginTop: 10 }} onClick={() => setOpen(!open)}>
        {open ? "▲ 閉じる" : "▼ この会社で使える端末（" + appr.length + "台）"}
      </button>
      {open
        ? (appr.length
            ? appr.map((x) => row(x, (
                <span onClick={() => act(x.id, "reject")}
                      style={{ ...S.chipDim, padding: "7px 12px", color: "var(--danger)",
                               cursor: busy ? "default" : "pointer", opacity: busy ? .5 : 1 }}>使えなくする</span>
              )))
            : <div style={{ ...S.sub, marginTop: 8 }}>まだありません。</div>)
        : null}
      <div style={{ ...S.sub, marginTop: 10, lineHeight: 1.7 }}>
        ★いまは<b>記録だけ</b>の段階です。承認していない端末も今までどおり使えます。
      </div>
    </div>
  );
}

function Home({ company, onOpen, onOpenMember, onOpenVisibility, onOpenPlacement, onOut }) {
  const [jobs, setJobs] = useState(null);
  const [inbox, setInbox] = useState(null);      // {id,count}
  const [members, setMembers] = useState([]);
  const [device, setDevice] = useState(loadDevice);
  const [asking, setAsking] = useState(false);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState("");
  const [result, setResult] = useState(null);
  const [title, setTitle] = useState("");
  const [adding, setAdding] = useState(false);
  const [writing, setWriting] = useState(false);   // ★声のかわりに文で書く（入力欄を開いているか）
  const [memo, setMemo] = useState("");

  const reload = () => {
    apiGET(withViewer("/api/jobs")).then((j) => setJobs(j.ok ? (j.jobs || []) : [])).catch(() => setJobs([]));
    apiGET(withViewer("/api/inbox")).then((j) => setInbox(j && j.ok ? { id: j.id, count: j.count } : null)).catch(() => {});
  };
  const reloadMembers = () => apiGET("/api/members")
    .then((j) => setMembers((j && j.members) || [])).catch(() => setMembers([]));
  useEffect(() => { reload(); reloadMembers(); }, []);

  /* ★ウィザードを出す条件＝名前が無い、または区分(kind)が未設定。
     今日までの端末は {id,name} しか持っていないので、1回だけウィザードを通って区分と役職を埋める（名前は引き継ぐ）。 */
  const needSetup = !device || !device.name || !device.kind;
  const roleLabel = device && device.kind
    ? (device.kind === "staff" ? (device.role || "店側スタッフ") : "キャスト") : "";

  /* ★本丸の送信先＝/api/handoffs/auto（jobを指定しない）。サーバが顧客名・日付を抽出して仕分ける。 */
  const upload = (blob) => {
    setBusy(true); setMsg("文字起こし中…（10〜30秒ほど）"); setResult(null);
    const fd = new FormData();
    fd.append("audio", blob, "handoff");
    fd.append("by", (device && device.name) || "");        // ★③録音者＝端末に紐付いた名前
    fd.append("deviceId", (device && device.id) || "");
    fd.append("kind", (device && device.kind) || "");      // ★カレンダー用タイトルの頭に使う（店側=名前（役職）／キャスト=源氏名）
    fd.append("role", (device && device.role) || "");
    fetch("/api/handoffs/auto", { method: "POST", credentials: "same-origin", body: fd })
      .then((r) => r.json().catch(() => null).then((j) => ({ status: r.status, j })))
      .then((res) => {
        setBusy(false); setMsg("");
        const j = res && res.j;
        if (res && res.status === 200 && j && j.ok) { setResult(j); reload(); return; }
        const st = res && res.status;
        // ★STT失敗(502)でも録音は未分類に確定済＝取りこぼさない。
        if (st === 502 && j && j.err === "stt_failed") {
          setResult({ routing: "unsorted", jobId: j.jobId, jobTitle: j.jobTitle, customer: "",
                      spokenDate: j.spokenDate, by: j.by, sttFailed: true });
          reload(); return;
        }
        if (st === 400) alert("この端末の録音形式に対応できませんでした（別の端末や、標準のボイスメモでお試しください）。");
        else if (st === 402) alert("未分類がいっぱいです。先に振り分けてください。");
        else if (st === 503) alert("音声の保管先に接続できませんでした。時間をおいてお試しください。");
        else alert("音声を送れませんでした（もう一度お試しください）。");
        reload();
      })
      .catch(() => { setBusy(false); setMsg(""); alert("通信に失敗しました（音声メモ）。保存済みの分を読み直します。"); reload(); });
  };
  const R = useRecorder(upload);

  /* ★声が苦手な人向け＝文で書いて同じ入口へ。送り先が /api/handoffs/auto-text になるだけで、
     サーバ側の後段（要約→予定抽出→カレンダー→客名の振り分け）は声とまったく同じ。 */
  const sendText = () => {
    const t = (memo || "").trim();
    if (!t || busy) return;
    setBusy(true); setMsg("読み取り中…（5〜15秒ほど）"); setResult(null);
    apiJSON("/api/handoffs/auto-text", "POST",
            { text: t, by: (device && device.name) || "", deviceId: (device && device.id) || "",
              kind: (device && device.kind) || "", role: (device && device.role) || "" })
      .then((res) => {
        setBusy(false); setMsg("");
        const j = res && res.j;
        if (res && res.status === 200 && j && j.ok) { setMemo(""); setWriting(false); setResult(j); reload(); return; }
        const st = res && res.status;
        if (st === 400) alert("申し送りを入力してください。");
        else if (st === 402) alert("未分類がいっぱいです。先に振り分けてください。");
        else alert("申し送りを送れませんでした（もう一度お試しください）。");
        reload();
      })
      .catch(() => { setBusy(false); setMsg(""); alert("通信に失敗しました（文の申し送り）。"); reload(); });
  };

  const create = () => {
    const t = (title || "").trim();
    if (!t || adding) return;
    setAdding(true);
    apiJSON("/api/jobs", "POST", { title: t }).then((res) => {
      setAdding(false);
      if (res && res.status === 200 && res.j && res.j.ok) { setTitle(""); reload(); onOpen(res.j.id, t, false); return; }
      alert("お客様を登録できませんでした（もう一度お試しください）。");
    }).catch(() => { setAdding(false); alert("通信に失敗しました。"); });
  };

  const customers = (jobs || []).filter((j) => !j.is_inbox);
  /* ★カレンダー登録はサーバが別スレッドで後から行う＝この結果カードが出た時点ではまだ結果が無い。
     そこで「予定がある時だけ」既存の取得APIを数回だけ叩き直し、実際に入ったか(gcalId)／入らなかったか(gcalError)を
     結果カードと読み上げに反映する。★Googleを見に行くのではなく自社APIを読むだけ（段階2の実在確認はしない）。 */
  useEffect(() => {
    if (!result || !result.jobId || !result.id) return;
    if (!((result.events || []).length)) return;
    if (calStatOf(result).done) return;
    let alive = true;
    const timers = [];
    const tick = () => {
      apiGET(withViewer("/api/jobs/" + result.jobId + "/handoffs"))
        .then((j) => {
          if (!alive || !j || !j.ok) return;
          const me = (j.handoffs || []).find((h) => String(h.id) === String(result.id));
          if (!me || !(me.events || []).length) return;
          setResult((prev) => (prev && String(prev.id) === String(result.id)
                               ? { ...prev, events: me.events } : prev));
        })
        .catch(() => {});
    };
    [2000, 5000, 9000].forEach((ms) => timers.push(setTimeout(tick, ms)));
    return () => { alive = false; timers.forEach(clearTimeout); };
  }, [result && result.id, result && (result.events || []).length, calStatOf(result).done]);

  const rv = result ? (ROUTE_VIEW[result.routing] || ROUTE_VIEW.unsorted) : null;

  return (
    <div style={S.page}>
      <div style={{ display: "flex", alignItems: "center", marginBottom: 14 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 22, fontWeight: 900, color: "var(--accent)", letterSpacing: ".04em" }}>こえろぐ</div>
          <div style={S.sub}>{company.name}</div>
        </div>
        <RefreshButton rec={R.rec} busy={busy} />
        <button style={{ ...S.btnGhost, width: "auto", padding: "8px 12px", fontSize: 12, marginLeft: 8 }}
                onClick={onOut}>ログアウト</button>
      </div>

      {/* ③録音者＝端末に紐付いた名前。初回だけ聞き、以降はここに出るだけ（押せば変えられる）。 */}
      {needSetup || asking
        ? <SetupWizard members={members} device={device}
                       onDone={(d) => { setDevice(d); setAsking(false); reloadMembers(); }}
                       onCancel={needSetup ? null : () => setAsking(false)} />
        : <button onClick={() => setAsking(true)}
                  style={{ ...S.btnGhost, width: "auto", padding: "6px 12px", fontSize: 12, marginBottom: 12 }}>
            録音者：<b style={{ color: "var(--text)" }}>{device.name}</b>（{roleLabel}・この端末）✎
          </button>}

      {/* ── 本丸：でかいボタン1個。お客様は選ばない。 ── */}
      {!needSetup ? (
        <div style={S.card}>
          <BigToggle rec={R.rec} secs={R.secs} busy={busy} msg={msg}
                     onToggle={() => R.toggle(busy)} wake={R.wake}
                     idleTitle="声で申し送り" idleHint="押す → 話す → もう一度押す" />
          {!R.rec && !busy
            ? <div style={{ fontSize: 12, color: "var(--text-dim)", marginTop: 12, lineHeight: 1.9 }}>
                お客様のお名前と「いつの話か」を一言入れてください（例：「昨日の山田様、お飲み物は…」）。
                <br />お名前からカルテを自動で選びます。分からなければ<b>未分類</b>に残ります。
              </div>
            : null}
          {/* ★声が苦手な人・静かな場所用。押した時だけ入力欄が開く＝普段は1行ぶんしか場所を取らない。
              送り先が違うだけで、要約も予定もカレンダーも声と同じ扱いになる。 */}
          {!R.rec ? (
            writing
              ? <div style={{ marginTop: 12 }}>
                  <textarea value={memo} onChange={(e) => setMemo(e.target.value)} rows={3} maxLength={5000}
                            placeholder="例）明日10時に山田様と同伴です"
                            style={{ ...S.input, fontSize: 15, resize: "vertical" }} />
                  <button style={{ ...S.btn, marginTop: 10, opacity: (busy || !memo.trim()) ? .5 : 1 }}
                          disabled={busy || !memo.trim()} onClick={sendText}>✍ この文で申し送り</button>
                  <button style={{ ...S.btnGhost, marginTop: 8 }}
                          onClick={() => { setWriting(false); setMemo(""); }}>閉じる</button>
                </div>
              : <button style={{ ...S.btnGhost, marginTop: 12 }} disabled={busy}
                        onClick={() => setWriting(true)}>✍ 声のかわりに文で書く</button>
          ) : null}
        </div>
      ) : null}

      {/* ── 直前の仕分け結果（どこへ入ったかを1枚で見せる） ── */}
      {result ? (
        <div style={{ ...S.card, marginTop: 12,
                      borderColor: rv.tone === "ok" ? "var(--ok)" : "var(--gold)" }}>
          {/* ★🔊読み上げはカードの【最上部】＝声で申し送った直後、スクロールせずに親指で押せる位置に置く。
              押した時だけ喋る（自動再生はしない）。読む中身＝カレンダーに入るのと同じ本文。 */}
          <SpeakButton src={result} big />
          <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
            <span style={rv.tone === "ok" ? S.chipOk : S.chip}>{rv.chip}</span>
            {result.spokenDate ? <span style={S.chipDim}>{fmtDay(result.spokenDate)}{result.dateSource === "ai" ? "（声から）" : "（録音日）"}</span> : null}
            {result.by ? <span style={S.chipDim}>録音：{result.by}</span> : null}
          </div>
          <div style={{ fontSize: 16, fontWeight: 800, marginTop: 10, lineHeight: 1.7 }}>
            {rv.tone === "ok"
              ? <React.Fragment><b style={{ color: "var(--accent)" }}>{result.jobTitle}</b>{rv.lead}</React.Fragment>
              : result.sttFailed ? "文字起こしができませんでした。録音は未分類に残しています。"
              : result.routing === "ambiguous" ? "同じお名前のカルテが複数あります。未分類に置きました。"
              : "お客様のお名前を取れませんでした。未分類に置きました。"}
          </div>
          {result.dateExpr ? <div style={{ ...S.sub, marginTop: 6 }}>「{result.dateExpr}」→ {fmtDay(result.spokenDate)}</div> : null}
          {(result.events || []).length ? (
            <div style={{ marginTop: 10, padding: "10px 12px", borderRadius: 10,
                          background: "var(--gold-soft)", border: "1px solid var(--gold)" }}>
              {result.events.map((e, i) => (
                <div key={i} style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.8 }}>
                  📅 {fmtDay(e.date)}{e.time ? " " + e.time : ""}　{e.calendar_title || e.title}
                  {e.kind && (e.calendar_title || e.title || "").indexOf(e.kind) < 0
                    ? <span style={{ ...S.chipDim, marginLeft: 8 }}>{e.kind}</span> : null}
                </div>
              ))}
            </div>
          ) : null}
          {/* ★実際に入ったかどうか（サーバがGoogleの応答で判定した結果だけを出す） */}
          <CalendarResult src={result} />
          {result.summary
            ? <div style={{ fontSize: 13, marginTop: 10, padding: 12, borderRadius: 10,
                            background: "var(--accent-soft)", whiteSpace: "pre-wrap", lineHeight: 1.75 }}>{result.summary}</div>
            : null}
          <button style={{ ...S.btn, marginTop: 12 }}
                  onClick={() => onOpen(result.jobId, result.jobTitle, rv.tone !== "ok")}>
            {rv.tone === "ok" ? "このカルテを開く" : "未分類を開いて振り分ける"}
          </button>
          <button style={{ ...S.btnGhost, marginTop: 8 }} onClick={() => setResult(null)}>閉じる</button>
        </div>
      ) : null}

      {/* ── 未分類トレイ（件数があれば目立たせる） ── */}
      {inbox && inbox.count > 0 ? (
        <button onClick={() => onOpen(inbox.id, "未分類", true)}
                style={{ ...S.card, marginTop: 14, width: "100%", textAlign: "left", cursor: "pointer",
                         display: "flex", alignItems: "center", gap: 12, borderColor: "var(--gold)" }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 16, fontWeight: 800 }}>📥 未分類</div>
            <div style={{ ...S.sub, marginTop: 4 }}>振り分け待ち {inbox.count} 件</div>
          </div>
          <div style={{ fontSize: 20, color: "var(--text-dimmer)" }}>›</div>
        </button>
      ) : null}

      {/* ── お客様カルテ一覧＋手で登録（自動で作られたものには印） ── */}
      <div style={{ marginTop: 20 }}>
        <div style={{ ...S.label, marginBottom: 8 }}>お客様カルテ</div>
        {jobs === null ? <div style={{ ...S.sub, padding: "8px 2px" }}>読み込み中…</div>
          : customers.length === 0 ? <div style={{ ...S.sub, padding: "8px 2px" }}>まだありません。上のボタンで声を残すと、お名前から自動で作られます。</div>
          : (
            <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
              {customers.map((j) => (
                <button key={j.id} onClick={() => onOpen(j.id, j.title, false)}
                        style={{ ...S.card, textAlign: "left", cursor: "pointer", display: "flex",
                                 alignItems: "center", gap: 12, padding: 14 }}>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 16, fontWeight: 800, color: "var(--text)", wordBreak: "break-word" }}>
                      {j.title}{j.auto_created ? <span style={{ ...S.chipDim, marginLeft: 8 }}>声から自動作成</span> : null}
                    </div>
                    <div style={{ ...S.sub, marginTop: 4 }}>申し送り {j.count} 件</div>
                  </div>
                  <div style={{ fontSize: 20, color: "var(--text-dimmer)" }}>›</div>
                </button>
              ))}
            </div>
          )}
      </div>

      <div style={{ ...S.card, marginTop: 16 }}>
        <label style={S.label}>お客様を手で登録する（任意）</label>
        <input style={S.input} value={title} maxLength={120} placeholder="例）山田様（金曜・本指名）"
               onChange={(e) => setTitle(e.target.value)}
               onKeyDown={(e) => { if (e.key === "Enter") create(); }} />
        <button style={{ ...S.btnGhost, marginTop: 10, opacity: (adding || !title.trim()) ? .5 : 1 }}
                disabled={adding || !title.trim()} onClick={create}>＋ 登録する</button>
      </div>

      {/* 名簿＝スタッフ／キャストの事前登録（本人がウィザードで登録するのと同じ器） */}
      <div style={{ ...S.card, marginTop: 16 }}>
        <label style={S.label}>名簿（スタッフ・キャスト）{members.length ? "　" + members.length + "名" : ""}</label>
        {members.length ? (
          <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
            {members.map((m) => (
              /* 押すと履歴書（9項目）が開く。誰が編集してよいかの制御は次段の役職マトリクス。 */
              <button key={m.id} onClick={() => onOpenMember(m)}
                      style={{ ...S.chipDim, padding: "6px 11px", border: "1px solid var(--border)",
                               cursor: "pointer", fontSize: 12 }}>
                {m.name}{m.kind === "staff" ? "・" + (m.role || "店側") : m.kind === "cast" ? "・キャスト" : ""} ›
              </button>
            ))}
          </div>
        ) : <div style={S.sub}>まだ登録がありません。</div>}
        <div style={{ ...S.sub, marginTop: 8 }}>名前を押すと履歴書（本名・生年月日・顔写真など）と<b>所属店</b>を設定できます。</div>
        <RosterAdd onAdded={reloadMembers} />
      </div>

      {/* ★端末の台帳（第1段）＝誰がどの端末を使っているかを見えるようにするだけ。
          この段階では未承認でも今までどおり全部使える＝締め出しはしていない。 */}
      <DeviceApproval />

      {/* ★可視設定・配置は店側スタッフだけ（キャストには出さない） */}
      {device && device.kind === "staff" ? (
        <React.Fragment>
          <button style={{ ...S.btn, marginTop: 12, background: "var(--accent-2)" }} onClick={onOpenPlacement}>
            🔎 誰をどの店に（人物像から配置を見立てる）
          </button>
          <button style={{ ...S.btnGhost, marginTop: 10 }} onClick={onOpenVisibility}>
            ⚙ 誰の申し送りが誰に見えるかを設定する
          </button>
          <GoogleCard />
        </React.Fragment>
      ) : null}
    </div>
  );
}

/* ───────── 引き継ぎタイムライン（音声＋文・確認・振り分け）───────── */
const STAGES = ["初来店", "ご来店", "接客中", "お見送り", "同伴・アフター", "その他"];

/* ★カルテ名に敬称を1つだけ付ける（ボタンの文言用・保存値には一切影響しない）。
   カルテの表題は「山田様」や「山田様（金曜・本指名）」の形で入っていることが多いので、
   敬称が【どこかに含まれていればそのまま】＝「山田様様」を作らない。無い時だけ「様」を足す。
   敬称の並びは backend の _HONORIFICS と揃えてある。 */
const HONORIFICS = ["様", "さま", "サマ", "さん", "サン", "ちゃん", "チャン", "君", "くん", "氏", "殿", "どの", "先生"];
const withHonorific = (name) => {
  const n = (name || "").trim();
  if (!n) return "お客様";
  return HONORIFICS.some((h) => n.indexOf(h) >= 0) ? n : n + "様";
};

function Timeline({ job, onBack }) {
  const jobId = job.id;
  const isInbox = !!job.isInbox;
  const client = withHonorific(job.title);       // ★ボタン文言に差し込む表示名（「山田様」）
                                                 //   ※下の who（「誰が」＝確認者の自己申告）とは別物
  const [items, setItems] = useState(null);      // null=読込中／[]=0件
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState("");            // 状態文言
  const [members, setMembers] = useState([]);
  const [jobs, setJobs] = useState([]);          // 振り分け先の候補
  const [device] = useState(loadDevice);
  const [who, setWho] = useState("");            // 「誰が」＝自己申告（members の id 文字列）
  const [stage, setStage] = useState("その他");
  const [mode, setMode] = useState("");          // ""=入口／"text"=文入力
  const [txt, setTxt] = useState("");
  const [cbusy, setCbusy] = useState("");        // 確認PUT中の hid（多重押下抑止）
  const [assign, setAssign] = useState({});      // hid -> 選択中の宛先 jobId
  const [abusy, setAbusy] = useState("");

  const reload = () => apiGET(withViewer("/api/jobs/" + jobId + "/handoffs"))
    .then((j) => setItems(j.ok ? (j.handoffs || []) : [])).catch(() => setItems([]));
  const reloadJobs = () => apiGET(withViewer("/api/jobs"))
    .then((j) => setJobs(((j && j.jobs) || []).filter((x) => !x.is_inbox && x.id !== jobId))).catch(() => setJobs([]));
  useEffect(() => {
    let alive = true;
    apiGET(withViewer("/api/jobs/" + jobId + "/handoffs"))
      .then((j) => { if (alive) setItems(j.ok ? (j.handoffs || []) : []); })
      .catch(() => { if (alive) setItems([]); });
    apiGET("/api/members")
      .then((j) => { if (alive) setMembers((j && j.members) || []); })
      .catch(() => { if (alive) setMembers([]); });
    reloadJobs();
    return () => { alive = false; };
  }, [jobId]);

  const nameOf = (wid) => { const p = members.find((x) => String(x.id) === String(wid)); return p ? p.name : ("#" + wid); };
  const byName = () => (who ? nameOf(who) : ((device && device.name) || ""));   // ★③未選択なら端末の録音者名

  const upload = (blob) => {
    // ★このお客様に直接ぶら下げる経路（自動仕分けを通さない）。サーバ側で 文字起こし→要約 まで同期で走る。
    setBusy(true); setMsg("文字起こし中…（10〜30秒ほど）");
    const fd = new FormData();
    fd.append("audio", blob, "handoff");
    fd.append("by", byName());
    fd.append("stage", stage);
    fd.append("kind", (device && device.kind) || "");
    fd.append("role", (device && device.role) || "");
    fetch("/api/jobs/" + jobId + "/handoffs", { method: "POST", credentials: "same-origin", body: fd })
      .then((r) => r.json().catch(() => null).then((j) => ({ status: r.status, j })))
      .then((res) => {
        setBusy(false); setMsg("");
        if (res && res.status === 200 && res.j && res.j.ok) { reload(); return; }
        const st = res && res.status;
        if (st === 502 && res.j && res.j.err === "stt_failed") {
          reload(); alert("文字起こしに失敗しましたが、録音は保存されました（下で聞けます）。\nOPENAI_API_KEY をご確認ください。"); return;
        }
        if (st === 400) alert("この端末の録音形式に対応できませんでした（別の端末や、標準のボイスメモでお試しください）。");
        else if (st === 402) alert("このお客様の申し送りがいっぱいです（上限に達しました）。");
        else if (st === 503) alert("音声の保管先に接続できませんでした。時間をおいてお試しください。");
        else alert("音声を送れませんでした（もう一度お試しください）。");
        reload();
      })
      .catch(() => { setBusy(false); setMsg(""); alert("通信に失敗しました（音声メモ）。保存済みの分を読み直します。"); reload(); });
  };
  const R = useRecorder(upload);

  const submitText = () => {
    const t = (txt || "").trim();
    if (!t || busy) return;
    setBusy(true); setMsg("送信中…");
    apiJSON("/api/jobs/" + jobId + "/handoffs/text", "POST",
            { by: byName(), text: t, stage: stage,
              kind: (device && device.kind) || "", role: (device && device.role) || "" })
      .then((res) => {
        setBusy(false); setMsg("");
        if (res && res.status === 200 && res.j && res.j.ok) { setTxt(""); setMode(""); reload(); return; }
        const st = res && res.status;
        if (st === 402) alert("このお客様の申し送りがいっぱいです（上限に達しました）。");
        else if (st === 400) alert("申し送りを入力してください。");
        else alert("申し送りを送れませんでした（もう一度お試しください）。");
      })
      .catch(() => { setBusy(false); setMsg(""); alert("通信に失敗しました（申し送り）。"); reload(); });
  };

  const confirmHandoff = (hid, remove) => {
    if (!who || cbusy) return;
    setCbusy(hid);
    apiJSON(withViewer("/api/jobs/" + jobId + "/handoffs/" + hid + "/confirm"), "PUT",
            remove ? { who, remove: true } : { who })
      .then((res) => {
        setCbusy("");
        if (res && res.status === 200 && res.j && res.j.ok) {
          // 返ってきた confirmedBy でその1件だけ差し替え（一覧再取得より軽い・スクロール保持）
          setItems((cur) => (cur || []).map((h) => h.id === hid ? { ...h, confirmedBy: (res.j.confirmedBy || {}) } : h));
          return;
        }
        alert("確認を記録できませんでした（もう一度お試しください）。");
      })
      .catch(() => { setCbusy(""); alert("通信に失敗しました（確認）。"); });
  };

  /* ★振り分け＝未分類（や誤爆）を後から人の手で正しいカルテへ。消さずに動かすだけ。 */
  const doAssign = (hid) => {
    const sel = assign[hid];
    if (!sel || abusy) return;
    const body = (sel === "__new__") ? { title: (window.prompt("新しいお客様のお名前") || "").trim() }
                                     : { jobId: Number(sel) };
    if (sel === "__new__" && !body.title) return;
    setAbusy(hid);
    apiJSON("/api/jobs/" + jobId + "/handoffs/" + hid + "/assign", "PUT", body)
      .then((res) => {
        setAbusy("");
        if (res && res.status === 200 && res.j && res.j.ok) {
          setItems((cur) => (cur || []).filter((h) => h.id !== hid));
          reloadJobs();
          return;
        }
        alert("振り分けできませんでした（宛先がいっぱい、または見つかりません）。");
      })
      .catch(() => { setAbusy(""); alert("通信に失敗しました（振り分け）。"); });
  };

  const assignRow = (h, primary) => (
    <div style={{ display: "flex", gap: 8, marginTop: primary ? 0 : 8 }}>
      <select value={assign[h.id] || ""} style={{ ...S.input, padding: "11px 8px", flex: 1 }}
              onChange={(e) => setAssign((a) => ({ ...a, [h.id]: e.target.value }))}>
        <option value="">{primary ? "このお客様へ移す…" : "移す先を選ぶ…"}</option>
        {jobs.map((x) => <option key={x.id} value={String(x.id)}>{x.title}</option>)}
        <option value="__new__">＋ 新しいカルテを作る</option>
      </select>
      <button style={{ ...(primary ? S.btn : S.btnGhost), width: "auto", padding: "12px 16px",
                       opacity: (!assign[h.id] || abusy === h.id) ? .5 : 1 }}
              disabled={!assign[h.id] || abusy === h.id}
              onClick={() => doAssign(h.id)}>{abusy === h.id ? "…" : "移す"}</button>
    </div>
  );

  const total = (items || []).length;

  return (
    <div style={S.page}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14 }}>
        <button style={{ ...S.btnGhost, width: "auto", padding: "8px 12px", fontSize: 13 }} onClick={onBack}>← 戻る</button>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 17, fontWeight: 800, wordBreak: "break-word" }}>{isInbox ? "📥 未分類" : job.title}</div>
          <div style={S.sub}>{isInbox ? "振り分け待ち " : "申し送り "}{items === null ? "…" : total} 件</div>
        </div>
        <RefreshButton rec={R.rec} busy={busy} />
      </div>

      {isInbox
        ? <div style={{ ...S.card, borderColor: "var(--gold)", marginBottom: 14 }}>
            <div style={{ fontSize: 13, lineHeight: 1.8 }}>
              お名前が取れなかった／同じお名前のカルテが複数あった申し送りです。<b>消えていません</b>。
              各件の下の「このお客様へ移す」で振り分けてください。
            </div>
          </div>
        : null}

      {/* ── 記録パネル：このお客様に直接録る（自動仕分けを通さない指名録音） ── */}
      {!isInbox ? (
      <div style={S.card}>
        <BigToggle rec={R.rec} secs={R.secs} busy={busy} msg={msg}
                   onToggle={() => R.toggle(busy)} wake={R.wake}
                   idleTitle={client + "について申し送る"} idleHint="押す → 話す → もう一度押す" />
        {/* ★何を吹き込めばいいかの型（キャバの申し送り）。迷わせない用。録音中・処理中は出さない。 */}
        {!R.rec && !busy
          ? <div style={{ fontSize: 12, color: "var(--text-dim)", marginTop: 12, lineHeight: 1.9 }}>
              例）お飲み物・お酒の銘柄／タバコの銘柄／話題の好み・触れない話／同伴・アフターの約束／次に付く子への注意点
            </div>
          : null}

        {/* 任意＝未選択のままでも録れる（誰が=端末の録音者／場面=その他 で保存される）。 */}
        {!R.rec ? (
        <div style={{ display: "flex", gap: 10, marginTop: 14 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <label style={S.label}>誰が（任意）</label>
            <select value={who} onChange={(e) => setWho(e.target.value)} style={{ ...S.input, padding: "11px 8px" }}>
              <option value="">{device && device.name ? "（この端末＝" + device.name + "）" : "（選ばない）"}</option>
              {members.map((m) => <option key={m.id} value={String(m.id)}>{m.name}</option>)}
            </select>
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <label style={S.label}>場面（任意）</label>
            <select value={stage} onChange={(e) => setStage(e.target.value)} style={{ ...S.input, padding: "11px 8px" }}>
              {STAGES.map((s) => <option key={s} value={s}>{s}</option>)}
            </select>
          </div>
        </div>
        ) : null}

        {mode === "text"
          ? <div style={{ marginTop: 12 }}>
              <textarea value={txt} onChange={(e) => setTxt(e.target.value)} rows={3} maxLength={5000}
                placeholder="申し送りを文で残す（好み・注意点・次回へ）"
                style={{ ...S.input, fontSize: 15, resize: "vertical" }} />
              <button style={{ ...S.btn, marginTop: 10, opacity: (busy || !txt.trim()) ? .5 : 1 }}
                      disabled={busy || !txt.trim()} onClick={submitText}>✍ {client}について文で申し送る</button>
              <button style={{ ...S.btnGhost, marginTop: 8 }} onClick={() => { setMode(""); setTxt(""); }}>閉じる</button>
            </div>
          : <button style={{ ...S.btnGhost, marginTop: 10 }} disabled={busy || R.rec}
                    onClick={() => setMode("text")}>✍ {client}について文で申し送る</button>}
      </div>
      ) : null}

      {/* ── タイムライン ── */}
      <div style={{ marginTop: 22 }}>
        {items === null ? <div style={{ ...S.sub, padding: "6px 2px" }}>読み込み中…</div>
          : total === 0 ? <div style={{ ...S.sub, padding: "6px 2px" }}>
              {isInbox ? "振り分け待ちはありません。" : "まだ申し送りはありません。上の「🎤 " + client + "について申し送る」から。"}
            </div>
          : (
            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              {items.map((h) => (
                <div key={h.id} style={S.card}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
                    {/* ★②いつの話か＝実日付（声から取れた時はその印を出す） */}
                    {h.spokenDate
                      ? <span style={h.dateSource === "ai" ? S.chip : S.chipDim}>
                          {fmtDay(h.spokenDate)}{h.dateSource === "ai" ? "・声から" : ""}
                        </span>
                      : <span style={S.chip}>{h.stage || "その他"}</span>}
                    {h.customer ? <span style={S.chipDim}>お客様：{h.customer}</span> : null}
                    <span style={{ fontSize: 12, color: "var(--text-dim)" }}>
                      {h.kind === "text" ? "✍ 文" : "🎤 音声"}・{fmtAt(h.at)}{h.by ? "／録音：" + h.by : ""}
                    </span>
                  </div>
                  {h.dateExpr ? <div style={{ ...S.sub, marginTop: -4, marginBottom: 8 }}>（話の中の「{h.dateExpr}」から）</div> : null}

                  {/* ★声から拾った「これから先の予定」。カレンダー連携はまだしない＝拾えたことが分かる最小表示。 */}
                  {(h.events || []).length ? (
                    <div style={{ marginBottom: 10, padding: "10px 12px", borderRadius: 10,
                                  background: "var(--gold-soft)", border: "1px solid var(--gold)" }}>
                      {h.events.map((e, i) => (
                        <div key={i} style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.8, color: "var(--text)" }}>
                          📅 {fmtDay(e.date)}{e.time ? " " + e.time : ""}　{e.gcalTitle || e.calendar_title || e.title}
                          {/* ★カレンダーに入ったかの印（入っていなければ何も出さない＝未連携でも画面は変わらない） */}
                          {e.gcalId ? <span style={{ ...S.chipOk, marginLeft: 8 }}>カレンダー登録済</span> : null}
                          {/* ★入らなかった予定は黙って消さず「未登録」と出す（成功と見分けが付くように） */}
                          {!e.gcalId && e.gcalError
                            ? <span style={{ ...S.chip, marginLeft: 8, color: "var(--danger)",
                                             background: "var(--danger-soft)" }}>
                                カレンダー未登録{e.gcalError === "expired" || e.gcalError === "no_token" ? "（要再連携）" : ""}
                              </span>
                            : null}
                          {/* 種別がタイトルに既に入っている時はチップを出さない（「同伴同伴」を避ける） */}
                          {e.kind && (e.calendar_title || e.title || "").indexOf(e.kind) < 0
                            ? <span style={{ ...S.chipDim, marginLeft: 8 }}>{e.kind}</span> : null}
                          {e.source_expr ? <span style={{ fontSize: 11, color: "var(--text-dim)", marginLeft: 6 }}>（「{e.source_expr}」から）</span> : null}
                        </div>
                      ))}
                    </div>
                  ) : null}
                  {/* ★🔊読み上げ（この申し送りの予定／無ければ要約）。押した時だけ喋る＝自動再生しない */}
                  <SpeakButton src={h} style={{ marginTop: 0, marginBottom: 10 }} />

                  {h.kind === "text"
                    ? <React.Fragment>
                        <div style={{ fontSize: 15, whiteSpace: "pre-wrap", lineHeight: 1.75 }}>{h.text}</div>
                        {/* ★文で書いた申し送りも声と同じ後段を通るので、要約が付いていれば同じ形で出す。
                            カルテ内のメモ用（要約なし）は summary が空＝これまでどおり何も出ない。 */}
                        {h.summary
                          ? <div style={{ fontSize: 14, marginTop: 10, padding: 12, borderRadius: 10,
                                          background: "var(--accent-soft)", whiteSpace: "pre-wrap", lineHeight: 1.75 }}>
                              <span style={{ fontWeight: 800, color: "var(--accent-2)" }}>📝 AI要約</span>{"\n" + h.summary}
                            </div>
                          : null}
                      </React.Fragment>
                    : <React.Fragment>
                        {h.url ? <audio controls preload="none" src={h.url} style={{ width: "100%" }} />
                               : <div style={{ ...S.sub }}>（音声を読み込めませんでした）</div>}
                        {/* 要約 → 文字起こし の順（要約を主役に）。両方サーバのGETから。 */}
                        {h.summary
                          ? <div style={{ fontSize: 14, marginTop: 10, padding: 12, borderRadius: 10,
                                          background: "var(--accent-soft)", whiteSpace: "pre-wrap", lineHeight: 1.75 }}>
                              <span style={{ fontWeight: 800, color: "var(--accent-2)" }}>📝 AI要約</span>{"\n" + h.summary}
                            </div>
                          : null}
                        {h.transcript
                          ? <details style={{ marginTop: 10 }}>
                              <summary style={{ fontSize: 12, color: "var(--text-dim)", cursor: "pointer" }}>文字起こしを見る</summary>
                              <div style={{ fontSize: 13, color: "var(--text-2)", marginTop: 8,
                                            whiteSpace: "pre-wrap", lineHeight: 1.75 }}>{h.transcript}</div>
                            </details>
                          : <div style={{ ...S.sub, marginTop: 8 }}>（文字起こしはありません）</div>}
                      </React.Fragment>}

                  {/* ★振り分け（未分類は常時／通常カルテは誤爆の直しとして畳んで出す） */}
                  <div style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--divider)" }}>
                    {isInbox
                      ? <React.Fragment>
                          {h.routing === "ambiguous" && (h.candidates || []).length
                            ? <div style={{ ...S.sub, marginBottom: 8 }}>
                                「{h.customer}」に近いカルテが {h.candidates.length} 件あります。どれか選んでください。
                              </div>
                            : null}
                          {assignRow(h, true)}
                        </React.Fragment>
                      : <details>
                          <summary style={{ fontSize: 12, color: "var(--text-dim)", cursor: "pointer" }}>別のお客様へ移す</summary>
                          {assignRow(h, false)}
                        </details>}
                  </div>

                  {/* 「確認しました」＝本人の明示押下のみ（自動既読にしない）。confirmedBy=map{who:時刻}。
                      ★未分類では出さない＝そこは「振り分ける場所」であって「確認する場所」ではない
                        （記録パネルを隠しているので「誰が」も選べず、押せないボタンになるため）。 */}
                  {isInbox ? null : (
                  <div style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--divider)" }}>
                    {Object.keys(h.confirmedBy || {}).length > 0
                      ? <div style={{ fontSize: 14, marginBottom: 10, lineHeight: 1.8 }}>
                          {Object.keys(h.confirmedBy).map((w) => (
                            <div key={w} style={{ color: "var(--ok)" }}>✅ <b>{nameOf(w)}</b> 確認済
                              <span style={{ fontSize: 12, color: "var(--text-dim)" }}> {fmtAt(h.confirmedBy[w])}</span></div>
                          ))}
                        </div>
                      : <div style={{ ...S.sub, marginBottom: 10 }}>まだ誰も確認していません。</div>}
                    {!who
                      ? <div style={{ ...S.sub }}>確認するには上の「誰が」でお名前を選んでください。</div>
                      : (h.confirmedBy && h.confirmedBy[who])
                        ? <button style={{ ...S.btnGhost, opacity: cbusy === h.id ? .5 : 1 }} disabled={cbusy === h.id}
                                  onClick={() => confirmHandoff(h.id, true)}>✓ 確認済（あなた＝{nameOf(who)}）／取り消す</button>
                        : <button style={{ ...S.btn, background: "var(--ok)", opacity: cbusy === h.id ? .5 : 1 }}
                                  disabled={cbusy === h.id} onClick={() => confirmHandoff(h.id, false)}>
                            {cbusy === h.id ? "記録中…" : "✅ 確認しました"}</button>}
                  </div>
                  )}
                </div>
              ))}
            </div>
          )}
      </div>
    </div>
  );
}

/* ───────── 画面切替 ───────── */
function App() {
  /* ★初期値に「前回の記憶」を入れる＝2回目以降はログイン画面も“読み込み中…”も一切描かずに
       いきなり Home(🎤) が出る（アイコンを押す→即録音、に寄せる）。記憶が無ければ従来通り。 */
  const [company, setCompany] = useState(loadAuth);    // null=記憶なし
  const [checked, setChecked] = useState(false);       // /api/me の返事が来たか
  const [job, setJob] = useState(null);
  const [member, setMember] = useState(null);          // 履歴書を開いている相手
  const [vis, setVis] = useState(false);               // 可視マトリクスの編集画面
  const [place, setPlace] = useState(false);           // 配置最適化の画面

  useEffect(() => {
    // ★裏でサーバに確認する。認められなければ記憶を捨ててログイン画面へ（＝記憶は鍵の代わりにはならない）。
    //   通信断のときだけは何もしない＝オフラインで勝手にログアウトさせない。
    apiGET("/api/me")
      .then((j) => {
        if (j && j.ok && j.company) { saveAuth(j.company); setCompany(j.company); }
        else { clearAuth(); setCompany(null); }
      })
      .catch(() => {})
      .then(() => setChecked(true), () => setChecked(true));
  }, []);

  const logout = () => {
    if (!window.confirm("ログアウトします。次にこのアプリを開くと、またログインが必要になります。\nよろしいですか？")) return;
    clearAuth();                                        // ★先に端末の記憶を消す（通信が失敗しても栓は必ず効く）
    setJob(null); setMember(null); setVis(false); setPlace(false); setCompany(null); setChecked(true);
    apiJSON("/api/logout", "POST", {}).catch(() => {});  // サーバ側セッションも捨てる
  };

  // 記憶が無い時だけ、サーバの返事を待つ間 読み込み中を出す（セッションだけ生きている場合のちらつき防止）。
  if (!company && !checked) return <div style={{ ...S.page, ...S.sub, paddingTop: 60, textAlign: "center" }}>読み込み中…</div>;
  if (!company) return <Login onIn={(c) => { saveAuth(c); setCompany(c); setChecked(true); }} />;
  if (place) return <Placement onBack={() => setPlace(false)} />;
  if (vis) return <Visibility onBack={() => setVis(false)} />;
  if (member) return <Profile member={member} onBack={() => setMember(null)} />;
  if (job) return <Timeline job={job} onBack={() => setJob(null)} />;
  return <Home company={company}
               onOpen={(id, title, isInbox) => setJob({ id, title, isInbox: !!isInbox })}
               onOpenMember={(m) => setMember(m)}
               onOpenVisibility={() => setVis(true)}
               onOpenPlacement={() => setPlace(true)}
               onOut={logout} />;
}

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