// ---------- Unreachable customers — shared tab for every custom dashboard ----------
// Customers the AI keeps failing to reach on outbound calls, why (what each
// attempt returned, which time slots were already tried) and a recommended
// tactic. The client records a decision per customer; "Stop calling" puts
// the number on the do-not-call list so blasts skip it.
//
//   const uc = useUnreachable(scopedLogs, { defaultClientId });
//   <UnreachableTab model={uc} t={t} lang={lang} Info={AstroInfo} drill={drill} onDrill={toggleDrill} />
//   …and in the view's drill filter:
//   if (drill?.type === "uc") return unreachableMatches(l, drill, uc);

Object.assign(window.STRINGS.en, {
  "astro.tab.unreachable": "Unreachable",
  "angkasa.tab.unreachable": "Unreachable",
  "uc.title": "Customers not picking up",
  "uc.subtitle": "Not reached in their last {{n}}+ outbound attempts · {{range}}",
  "uc.info": "Every customer the AI called at least {{n}} times in a row without reaching a person. Each one gets a diagnosis from what those calls returned (no answer, busy, voicemail, number not connecting) and when they were made, plus a recommended next step. Record a decision per customer; \"Stop calling\" adds the number to the do-not-call list so future blasts skip it.",
  "uc.threshold": "Not reached in last",
  "uc.attempts": "{{n}} attempts",
  "uc.kpi.customers": "Unreachable customers",
  "uc.kpi.customersInfo": "Customers not reached in their last attempts (see the selector). Click to see all their calls.",
  "uc.kpi.owed": "Owed by them",
  "uc.kpi.owedInfo": "Total latest balance of these customers, from the campaign data.",
  "uc.kpi.pending": "Awaiting a decision",
  "uc.kpi.pendingInfo": "Unreachable customers nobody has decided on yet. Click to see their calls.",
  "uc.kpi.decided": "Decided",
  "uc.segments": "Why they're not reached",
  "uc.segmentsInfo": "Each customer is placed in the first group that fits. Click a group to filter the list below and open its calls.",
  "uc.seg.badNumber": "Number not connecting",
  "uc.seg.avoiding": "Answered before, not since",
  "uc.seg.rejecting": "Rejecting the call",
  "uc.seg.voicemail": "Phone off / voicemail",
  "uc.seg.sameTimes": "Only tried at the same times",
  "uc.seg.exhausted": "Tried all times, never reached",
  "uc.tac.badNumber": "Calls don't connect at all. Ask the client for an updated number and stop dialling this one.",
  "uc.tac.avoiding": "Picked up before but not since — likely avoiding the call. Send an SMS with a callback number, or hand to a human agent.",
  "uc.tac.rejecting": "The line is busy each time — they see the call and decline. Send an SMS first, then call at a different time.",
  "uc.tac.voicemail": "Straight to voicemail — the phone is often off. Try another day, or SMS so they see it when the phone is on.",
  "uc.tac.sameTimes": "Only tried {{tried}}. Next try {{next}} before giving up.",
  "uc.tac.exhausted": "Tried on different days and times without reaching anyone. Move to SMS or a human agent, or stop and return it to the client as uncontactable.",
  "uc.slot.wkMorning": "weekday mornings",
  "uc.slot.wkAfternoon": "weekday afternoons",
  "uc.slot.wkEvening": "weekday evenings (6–9pm)",
  "uc.slot.weekend": "weekends",
  "uc.res.noAnswer": "No answer",
  "uc.res.busy": "Busy / declined",
  "uc.res.voicemail": "Voicemail",
  "uc.res.badLine": "Didn't connect",
  "uc.res.reached": "Reached",
  "uc.col.customer": "Customer",
  "uc.col.balance": "Balance",
  "uc.col.attempts": "Not reached",
  "uc.col.last": "Last try",
  "uc.col.tried": "Times tried",
  "uc.col.why": "Why · recommended next step",
  "uc.col.decision": "Decision",
  "uc.calls": "Calls",
  "uc.recommended": "Recommended",
  "uc.dec.none": "— Decide —",
  "uc.dec.retry_other_time": "Retry at another time",
  "uc.dec.sms_first": "SMS first",
  "uc.dec.human_agent": "Hand to a human agent",
  "uc.dec.new_number": "Ask client for a new number",
  "uc.dec.stop": "Stop calling (do-not-call)",
  "uc.stopConfirm": "Stop calling {{name}}? The number goes on the do-not-call list and future blasts will skip it. You can undo this by changing the decision.",
  "uc.notePlaceholder": "Note (optional)",
  "uc.filter.all": "All",
  "uc.filter.pending": "Awaiting decision",
  "uc.export": "Export CSV",
  "uc.empty": "No customer has gone unreached for {{n}} attempts in a row in this range.",
  "uc.noOutbound": "No outbound calls in this range.",
  "uc.saveFailed": "Could not save: {{error}}",
  "uc.drill.all": "Unreachable customers",
  "uc.drill.pending": "Unreachable — awaiting decision",
  "uc.drill.customer": "Calls to {{name}}",
  "uc.tacticsInfo": "Good practice for customers who don't pick up: vary the time (weekday evenings and Saturday mornings reach working people), space retries out instead of redialling quickly, send an SMS with a callback number before or after a missed call, and keep the caller ID consistent so it isn't marked as spam. Never exceed the contact limits (3 attempts a day, 8am–9pm).",
});

Object.assign(window.STRINGS.ms, {
  "astro.tab.unreachable": "Tidak dapat dihubungi",
  "angkasa.tab.unreachable": "Tidak dapat dihubungi",
  "uc.title": "Pelanggan yang tidak menjawab",
  "uc.subtitle": "Tidak dapat dihubungi dalam {{n}}+ cubaan keluar terakhir · {{range}}",
  "uc.info": "Setiap pelanggan yang dipanggil AI sekurang-kurangnya {{n}} kali berturut-turut tanpa dapat bercakap dengan sesiapa. Setiap seorang diberi diagnosis berdasarkan hasil panggilan (tiada jawapan, sibuk, peti suara, nombor tidak bersambung) dan masa panggilan dibuat, serta langkah seterusnya yang disyorkan. Rekod keputusan bagi setiap pelanggan; \"Henti hubungi\" memasukkan nombor ke senarai jangan hubungi supaya blast seterusnya melangkaunya.",
  "uc.threshold": "Tidak dapat dihubungi dalam",
  "uc.attempts": "{{n}} cubaan terakhir",
  "uc.kpi.customers": "Pelanggan tidak dapat dihubungi",
  "uc.kpi.customersInfo": "Pelanggan yang tidak dapat dihubungi dalam cubaan terakhir mereka (lihat pemilih). Klik untuk lihat semua panggilan mereka.",
  "uc.kpi.owed": "Jumlah tertunggak",
  "uc.kpi.owedInfo": "Jumlah baki terkini pelanggan ini, daripada data kempen.",
  "uc.kpi.pending": "Menunggu keputusan",
  "uc.kpi.pendingInfo": "Pelanggan tidak dapat dihubungi yang belum diputuskan. Klik untuk lihat panggilan mereka.",
  "uc.kpi.decided": "Sudah diputuskan",
  "uc.segments": "Sebab tidak dapat dihubungi",
  "uc.segmentsInfo": "Setiap pelanggan diletakkan dalam kumpulan pertama yang sesuai. Klik kumpulan untuk menapis senarai di bawah dan buka panggilannya.",
  "uc.seg.badNumber": "Nombor tidak bersambung",
  "uc.seg.avoiding": "Pernah jawab, kemudian tidak",
  "uc.seg.rejecting": "Menolak panggilan",
  "uc.seg.voicemail": "Telefon tutup / peti suara",
  "uc.seg.sameTimes": "Hanya dicuba pada masa yang sama",
  "uc.seg.exhausted": "Dicuba pada semua masa, tidak pernah dapat",
  "uc.tac.badNumber": "Panggilan langsung tidak bersambung. Minta nombor terkini daripada klien dan berhenti mendail nombor ini.",
  "uc.tac.avoiding": "Pernah jawab tetapi tidak lagi — mungkin mengelak. Hantar SMS dengan nombor untuk dihubungi semula, atau serahkan kepada ejen manusia.",
  "uc.tac.rejecting": "Talian sibuk setiap kali — dia nampak panggilan dan menolak. Hantar SMS dahulu, kemudian hubungi pada masa lain.",
  "uc.tac.voicemail": "Terus ke peti suara — telefon kerap ditutup. Cuba hari lain, atau SMS supaya dia nampak apabila telefon dibuka.",
  "uc.tac.sameTimes": "Hanya dicuba {{tried}}. Cuba seterusnya {{next}} sebelum berhenti.",
  "uc.tac.exhausted": "Sudah dicuba pada hari dan masa berbeza tanpa dapat bercakap dengan sesiapa. Tukar kepada SMS atau ejen manusia, atau berhenti dan pulangkan kepada klien sebagai tidak dapat dihubungi.",
  "uc.slot.wkMorning": "pagi hari bekerja",
  "uc.slot.wkAfternoon": "petang hari bekerja",
  "uc.slot.wkEvening": "malam hari bekerja (6–9 malam)",
  "uc.slot.weekend": "hujung minggu",
  "uc.res.noAnswer": "Tiada jawapan",
  "uc.res.busy": "Sibuk / ditolak",
  "uc.res.voicemail": "Peti suara",
  "uc.res.badLine": "Tidak bersambung",
  "uc.res.reached": "Dapat dihubungi",
  "uc.col.customer": "Pelanggan",
  "uc.col.balance": "Baki",
  "uc.col.attempts": "Tidak dapat",
  "uc.col.last": "Cubaan terakhir",
  "uc.col.tried": "Masa dicuba",
  "uc.col.why": "Sebab · langkah seterusnya disyorkan",
  "uc.col.decision": "Keputusan",
  "uc.calls": "Panggilan",
  "uc.recommended": "Disyorkan",
  "uc.dec.none": "— Pilih —",
  "uc.dec.retry_other_time": "Cuba pada masa lain",
  "uc.dec.sms_first": "SMS dahulu",
  "uc.dec.human_agent": "Serah kepada ejen manusia",
  "uc.dec.new_number": "Minta nombor baharu daripada klien",
  "uc.dec.stop": "Henti hubungi (jangan hubungi)",
  "uc.stopConfirm": "Henti hubungi {{name}}? Nombor ini akan masuk senarai jangan hubungi dan blast seterusnya akan melangkaunya. Boleh dibatalkan dengan menukar keputusan.",
  "uc.notePlaceholder": "Nota (pilihan)",
  "uc.filter.all": "Semua",
  "uc.filter.pending": "Menunggu keputusan",
  "uc.export": "Eksport CSV",
  "uc.empty": "Tiada pelanggan yang tidak dapat dihubungi {{n}} kali berturut-turut dalam julat ini.",
  "uc.noOutbound": "Tiada panggilan keluar dalam julat ini.",
  "uc.saveFailed": "Gagal menyimpan: {{error}}",
  "uc.drill.all": "Pelanggan tidak dapat dihubungi",
  "uc.drill.pending": "Tidak dapat dihubungi — menunggu keputusan",
  "uc.drill.customer": "Panggilan kepada {{name}}",
  "uc.tacticsInfo": "Amalan baik untuk pelanggan yang tidak menjawab: ubah masa (malam hari bekerja dan pagi Sabtu sesuai untuk orang bekerja), jarakkan cubaan dan jangan dail berulang kali dengan cepat, hantar SMS dengan nombor untuk dihubungi sebelum atau selepas panggilan tidak berjawab, dan kekalkan ID pemanggil yang sama supaya tidak ditanda spam. Jangan melebihi had hubungan (3 cubaan sehari, 8 pagi–9 malam).",
});

const UC_SEGMENTS = ["badNumber", "avoiding", "rejecting", "voicemail", "sameTimes", "exhausted"];
const UC_SEG_COLOR = { badNumber: "var(--err)", avoiding: "var(--warn)", rejecting: "var(--warn)", voicemail: "var(--info)", sameTimes: "var(--a)", exhausted: "var(--c-text-4)" };
const UC_SLOTS = ["wkMorning", "wkAfternoon", "wkEvening", "weekend"];
const UC_NEXT_SLOT_ORDER = ["wkEvening", "weekend", "wkMorning", "wkAfternoon"]; // reaches working people first
const UC_DECISIONS = ["retry_other_time", "sms_first", "human_agent", "new_number", "stop"];
const UC_NOT_REACHED = new Set(["customer-did-not-answer", "silence-timed-out", "voicemail", "customer-busy", "failed-to-connect", "call.error-processing-failed"]);

function ucIsOutbound(l) {
  const id = String(l?.id || "");
  if (id.startsWith("voxpro-inbound-")) return false;
  return id.startsWith("voxpro-live-") || /blast|outbound/i.test(String(l?.source || l?.campaignType || "")) || Boolean(l?.campaignId);
}

// What one attempt returned. "Reached" = the line connected AND the caller
// said something readable (the call-end logic records nothing-heard calls as
// customer-did-not-answer).
function ucResult(l) {
  const r = String(l?.endedReason || "").trim().toLowerCase();
  if (r === "voicemail") return "voicemail";
  if (r === "customer-busy") return "busy";
  if (r.includes("fail") || r.includes("error")) return "badLine";
  if (UC_NOT_REACHED.has(r) || !r) return "noAnswer";
  const spoke = (l?.turns || []).some(turn => String(turn.user || "").trim());
  return spoke ? "reached" : "noAnswer";
}

// Malaysia time (UTC+8, no DST) slot of an attempt.
function ucSlot(l) {
  const ms = new Date(l?.startedAt || l?.updatedAt || 0).getTime();
  if (!ms) return null;
  const my = new Date(ms + 8 * 3600 * 1000);
  const dow = my.getUTCDay();
  const hour = my.getUTCHours();
  const slot = dow === 0 || dow === 6 ? "weekend" : hour < 12 ? "wkMorning" : hour < 18 ? "wkAfternoon" : "wkEvening";
  return { slot, day: my.toISOString().slice(0, 10) };
}

function ucBalance(l) {
  const raw = l?.balance ?? l?.structuredOutput?.balance ?? (typeof csvRowValue === "function" ? csvRowValue(l?.campaignRow, "balance") : null);
  if (raw == null || raw === "") return null;
  const n = Number(String(raw).replace(/[^\d.]/g, ""));
  return Number.isFinite(n) ? n : null;
}

function ucAccount(l) {
  if (typeof csvRowValue !== "function") return String(l?.structuredOutput?.akaun_number || "");
  return String(csvRowValue(l?.campaignRow, "akaun_number") || csvRowValue(l?.campaignRow, "account") || l?.structuredOutput?.akaun_number || "");
}

const ucPhone = (l) => (typeof callPhoneNumber === "function" ? callPhoneNumber(l) : (l?.contact || l?.to || l?.contactId || ""));
const ucKey = (phone) => String(phone || "").replace(/\D/g, "").slice(-9);

function ucDiagnose(c) {
  const recent = c.sinceReached; // attempts after the last time they were reached
  const counts = { noAnswer: 0, busy: 0, voicemail: 0, badLine: 0 };
  recent.forEach(a => { counts[a.result] += 1; });
  const tried = UC_SLOTS.filter(s => recent.some(a => a.slot === s));
  const days = new Set(recent.map(a => a.day)).size;
  const next = UC_NEXT_SLOT_ORDER.find(s => !tried.includes(s)) || null;
  let segment;
  if (counts.badLine === recent.length) segment = "badNumber";
  else if (c.everReached) segment = "avoiding";
  else if (counts.busy >= 2) segment = "rejecting";
  else if (counts.voicemail >= 2 && counts.voicemail * 2 >= recent.length) segment = "voicemail";
  else if ((tried.length <= 1 || days <= 1) && next) segment = "sameTimes";
  else segment = "exhausted";
  const recommended = {
    badNumber: "new_number", avoiding: "human_agent", rejecting: "sms_first", voicemail: "sms_first", sameTimes: "retry_other_time",
    exhausted: c.balance != null && c.balance >= 100 ? "human_agent" : "sms_first",
  }[segment];
  return { segment, recommended, counts, tried, days, next };
}

function useUnreachable(logs = [], { defaultClientId = "" } = {}) {
  const [threshold, setThreshold] = React.useState(() => {
    try { return Number(localStorage.getItem("voxpro.ucThreshold")) || 3; } catch { return 3; }
  });
  const [decisions, setDecisions] = React.useState(new Map()); // key → entry
  const loadDecisions = React.useCallback(() => voxApi("/contact-decisions")
    .then(r => setDecisions(new Map((r.entries || []).map(e => [`${e.clientId}|${e.phoneKey}`, e]))))
    .catch(() => {}), []);
  React.useEffect(() => { loadDecisions(); }, [loadDecisions]);

  const model = React.useMemo(() => {
    const decisionsByPhone = new Map([...decisions.values()].map(e => [e.phoneKey, e]));
    const outbound = logs.filter(ucIsOutbound);
    const byKey = new Map();
    outbound.forEach(l => {
      const phone = ucPhone(l);
      const key = ucKey(phone);
      if (key.length < 7) return;
      const c = byKey.get(key) || { key, phone, name: "", account: "", balance: null, balanceAt: 0, clientId: "", attempts: [], logIds: new Set() };
      const at = new Date(l.startedAt || l.updatedAt || 0).getTime();
      const slot = ucSlot(l);
      c.attempts.push({ id: l.id, at, result: ucResult(l), slot: slot?.slot, day: slot?.day });
      c.logIds.add(l.id);
      c.name = c.name || l.customerName || "";
      c.account = c.account || ucAccount(l);
      if (l.clientId) c.clientId = l.clientId;
      const bal = ucBalance(l);
      if (bal != null && at >= c.balanceAt) { c.balance = bal; c.balanceAt = at; }
      byKey.set(key, c);
    });
    const contacts = [];
    byKey.forEach(c => {
      c.attempts.sort((a, b) => a.at - b.at);
      const lastReachedIdx = c.attempts.map(a => a.result).lastIndexOf("reached");
      c.everReached = lastReachedIdx >= 0;
      c.sinceReached = c.attempts.slice(lastReachedIdx + 1);
      if (c.sinceReached.length < threshold) return;
      c.clientId = c.clientId || defaultClientId;
      c.lastAt = c.attempts[c.attempts.length - 1].at;
      c.lastResult = c.attempts[c.attempts.length - 1].result;
      Object.assign(c, ucDiagnose(c));
      // Admin-launched blasts log clientId null; the server then files the
      // decision under the session's own client, so fall back to the number.
      c.decision = decisions.get(`${c.clientId}|${c.key}`) || (!c.clientId ? decisionsByPhone.get(c.key) : null) || null;
      contacts.push(c);
    });
    contacts.sort((a, b) => (Number(Boolean(a.decision)) - Number(Boolean(b.decision))) || ((b.balance || 0) - (a.balance || 0)) || (b.sinceReached.length - a.sinceReached.length));
    const logKey = new Map();
    contacts.forEach(c => c.logIds.forEach(id => logKey.set(id, c)));
    return { contacts, logKey, outboundCount: outbound.length };
  }, [logs, threshold, decisions, defaultClientId]);

  function changeThreshold(n) {
    setThreshold(n);
    try { localStorage.setItem("voxpro.ucThreshold", String(n)); } catch { /* storage unavailable */ }
  }

  async function decide(c, decision, note) {
    const r = await voxApi("/contact-decisions", {
      method: "PUT",
      body: JSON.stringify({ clientId: c.clientId || undefined, phone: c.phone, customerName: c.name, account: c.account, decision, note }),
    });
    setDecisions(prev => {
      const next = new Map(prev);
      // Drop whatever was stored for this number before (its clientId may
      // only be known from the server's answer).
      for (const [k, e] of next) if (e.phoneKey === c.key && (!c.clientId || e.clientId === c.clientId)) next.delete(k);
      if (r.entry) next.set(`${r.entry.clientId}|${r.entry.phoneKey}`, r.entry);
      return next;
    });
  }

  return { ...model, threshold, setThreshold: changeThreshold, decide, reload: loadDecisions };
}

// Drill filter shared by every view: drill = { type: "uc", value }.
function unreachableMatches(l, drill, model) {
  const c = model?.logKey?.get(l.id);
  if (!c) return false;
  const [kind, ...rest] = String(drill?.value || "").split(":");
  const arg = rest.join(":");
  if (kind === "all") return true;
  if (kind === "pending") return !c.decision;
  if (kind === "seg") return c.segment === arg;
  if (kind === "c") return c.key === arg;
  return true;
}

function ucCsv(rows, t) {
  const cols = ["customer", "phone", "account", "balance", "notReached", "totalAttempts", "lastTry", "timesTried", "lastResult", "why", "recommended", "decision", "note"];
  const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
  const lines = [cols.join(",")].concat(rows.map(c => [
    c.name, c.phone, c.account, c.balance ?? "", c.sinceReached.length, c.attempts.length,
    new Date(c.lastAt).toISOString(), c.tried.map(s => t(`uc.slot.${s}`)).join(" / "), t(`uc.res.${c.lastResult}`),
    t(`uc.seg.${c.segment}`), t(`uc.dec.${c.recommended}`), c.decision ? t(`uc.dec.${c.decision.decision}`) : "", c.decision?.note || "",
  ].map(esc).join(",")));
  const blob = new Blob([`﻿${lines.join("\n")}`], { type: "text/csv;charset=utf-8" });
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = `unreachable-customers-${new Date().toISOString().slice(0, 10)}.csv`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}

function UnreachableTab({ model, t, lang = "en", Info, drill, onDrill, rangeLabel = "" }) {
  const InfoIcon = Info || (() => null);
  const [segFilter, setSegFilter] = React.useState("");
  const [pendingOnly, setPendingOnly] = React.useState(false);
  const [saving, setSaving] = React.useState("");
  const [error, setError] = React.useState("");
  const [notes, setNotes] = React.useState({});
  if (!model) return null;
  const { contacts, threshold } = model;
  const active = (value) => drill?.type === "uc" && drill.value === value;
  const rows = contacts.filter(c => (!segFilter || c.segment === segFilter) && (!pendingOnly || !c.decision));
  const pending = contacts.filter(c => !c.decision).length;
  const owed = contacts.reduce((n, c) => n + (c.balance || 0), 0);
  const hasBalances = contacts.some(c => c.balance != null);
  const segCounts = Object.fromEntries(UC_SEGMENTS.map(s => [s, contacts.filter(c => c.segment === s).length]));
  const fmtDate = (ms) => new Date(ms).toLocaleString(lang === "ms" ? "ms-MY" : "en-MY", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
  const tactic = (c) => c.segment === "sameTimes"
    ? t("uc.tac.sameTimes", { tried: c.tried.map(s => t(`uc.slot.${s}`)).join(", ") || "—", next: t(`uc.slot.${c.next}`) })
    : t(`uc.tac.${c.segment}`);

  async function save(c, decision) {
    if (decision === "stop" && !window.confirm(t("uc.stopConfirm", { name: c.name || c.phone }))) return;
    setSaving(c.key);
    setError("");
    try {
      await model.decide(c, decision, notes[c.key] ?? c.decision?.note ?? "");
    } catch (e) {
      setError(t("uc.saveFailed", { error: e.message }));
    } finally {
      setSaving("");
    }
  }

  const tile = (labelKey, infoKey, value, drillValue, clickable, color) => (
    <div className="stat" style={{ cursor: clickable ? "pointer" : undefined, background: active(drillValue) ? "rgba(141,139,255,0.09)" : undefined }}
      onClick={() => clickable && onDrill("uc", drillValue, t(`uc.drill.${drillValue}`))}>
      <div className="stat-label">{t(labelKey)}<InfoIcon text={t(infoKey)} /></div>
      <div className="stat-value" style={{ color }}>{value}</div>
    </div>
  );

  return (
    <Card
      title={<>{t("uc.title")}<InfoIcon text={t("uc.info", { n: threshold })} /></>}
      subtitle={t("uc.subtitle", { n: threshold, range: rangeLabel })}
      action={(
        <div className="row gap-2" style={{ alignItems: "center", flexWrap: "wrap" }}>
          <span className="muted text-11">{t("uc.threshold")}</span>
          <Select value={threshold} onChange={e => model.setThreshold(Number(e.target.value))} style={{ width: "auto" }}>
            {[2, 3, 4, 5, 8].map(n => <option key={n} value={n}>{t("uc.attempts", { n })}</option>)}
          </Select>
          <Btn size="sm" variant="ghost" onClick={() => ucCsv(rows, t)} disabled={!rows.length}>{t("uc.export")}</Btn>
        </div>
      )}
    >
      {!model.outboundCount ? (
        <div className="muted text-12">{t("uc.noOutbound")}</div>
      ) : !contacts.length ? (
        <div className="muted text-12">{t("uc.empty", { n: threshold })}</div>
      ) : (
        <div className="col gap-4">
          <div className="g-3">
            {tile("uc.kpi.customers", "uc.kpi.customersInfo", contacts.length.toLocaleString(), "all", true)}
            {hasBalances && tile("uc.kpi.owed", "uc.kpi.owedInfo", `RM ${owed.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, "all", true)}
            {tile("uc.kpi.pending", "uc.kpi.pendingInfo", pending.toLocaleString(), "pending", pending > 0, pending ? "var(--warn)" : "var(--ok)")}
          </div>

          <div>
            <div className="stat-label mb-2">{t("uc.segments")}<InfoIcon text={t("uc.segmentsInfo")} /><InfoIcon text={t("uc.tacticsInfo")} /></div>
            <div className="col gap-2">
              {UC_SEGMENTS.filter(s => segCounts[s]).map(s => (
                <AngkasaBarRow key={s} label={t(`uc.seg.${s}`)} value={segCounts[s].toLocaleString()} count={segCounts[s]}
                  max={Math.max(1, ...Object.values(segCounts))} color={UC_SEG_COLOR[s]} active={segFilter === s}
                  onClick={() => { const next = segFilter === s ? "" : s; setSegFilter(next); if (next) onDrill("uc", `seg:${s}`, t(`uc.seg.${s}`)); }} />
              ))}
            </div>
          </div>

          <div>
            <div className="row gap-2 mb-2" style={{ flexWrap: "wrap" }}>
              <Btn size="sm" variant={!pendingOnly ? "primary" : "ghost"} onClick={() => setPendingOnly(false)}>{t("uc.filter.all")} ({contacts.filter(c => !segFilter || c.segment === segFilter).length})</Btn>
              <Btn size="sm" variant={pendingOnly ? "primary" : "ghost"} onClick={() => setPendingOnly(true)}>{t("uc.filter.pending")} ({contacts.filter(c => (!segFilter || c.segment === segFilter) && !c.decision).length})</Btn>
              {segFilter && <Btn size="sm" variant="ghost" onClick={() => setSegFilter("")}>× {t(`uc.seg.${segFilter}`)}</Btn>}
            </div>
            {error && <div className="text-12 mb-2" style={{ color: "var(--err)" }}>{error}</div>}
            <div style={{ overflowX: "auto" }}>
              <table className="table" style={{ width: "100%", fontSize: 12 }}>
                <thead>
                  <tr>
                    <th>{t("uc.col.customer")}</th>
                    {hasBalances && <th>{t("uc.col.balance")}</th>}
                    <th>{t("uc.col.attempts")}</th>
                    <th>{t("uc.col.last")}</th>
                    <th>{t("uc.col.tried")}</th>
                    <th style={{ minWidth: 220 }}>{t("uc.col.why")}</th>
                    <th style={{ minWidth: 200 }}>{t("uc.col.decision")}</th>
                  </tr>
                </thead>
                <tbody>
                  {rows.map(c => (
                    <tr key={c.key} style={{ background: active(`c:${c.key}`) ? "rgba(141,139,255,0.09)" : undefined }}>
                      <td>
                        <div className="strong">{c.name || "—"}</div>
                        <div className="muted mono text-11">{c.phone}{c.account ? ` · ${c.account}` : ""}</div>
                        <span role="button" className="text-11" style={{ cursor: "pointer", color: "var(--a)" }}
                          onClick={() => onDrill("uc", `c:${c.key}`, t("uc.drill.customer", { name: c.name || c.phone }))}>{t("uc.calls")} ({c.attempts.length}) →</span>
                      </td>
                      {hasBalances && <td>{c.balance != null ? `RM ${c.balance.toFixed(2)}` : "—"}</td>}
                      <td>
                        <div className="strong">{c.sinceReached.length}×</div>
                        <div className="muted text-11">{Object.entries(c.counts).filter(([, n]) => n).map(([k, n]) => `${t(`uc.res.${k}`)} ${n}`).join(" · ")}</div>
                      </td>
                      <td className="muted">{fmtDate(c.lastAt)}</td>
                      <td className="muted text-11">{c.tried.map(s => t(`uc.slot.${s}`)).join(", ") || "—"}</td>
                      <td>
                        <div className="strong" style={{ color: UC_SEG_COLOR[c.segment] }}>{t(`uc.seg.${c.segment}`)}</div>
                        <div className="text-11">{tactic(c)}</div>
                      </td>
                      <td>
                        <Select value={c.decision?.decision || ""} disabled={saving === c.key} onChange={e => save(c, e.target.value)}>
                          <option value="">{t("uc.dec.none")}</option>
                          {UC_DECISIONS.map(d => (
                            <option key={d} value={d}>{t(`uc.dec.${d}`)}{d === c.recommended ? ` ★ ${t("uc.recommended")}` : ""}</option>
                          ))}
                        </Select>
                        {c.decision && (
                          <Input style={{ marginTop: 4 }} placeholder={t("uc.notePlaceholder")} value={notes[c.key] ?? c.decision.note ?? ""}
                            onChange={e => setNotes(n => ({ ...n, [c.key]: e.target.value }))}
                            onBlur={() => (notes[c.key] ?? null) !== null && notes[c.key] !== (c.decision.note || "") && save(c, c.decision.decision)} />
                        )}
                        {c.decision && <div className="muted text-11 mt-1">{c.decision.decidedBy} · {fmtDate(new Date(c.decision.decidedAt).getTime())}</div>}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        </div>
      )}
    </Card>
  );
}
