'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { AssignmentBrief, AssignmentDetailResponse } from '@pac/types';
import { RELEASE_REASON_META, type ReleaseReason } from '@pac/types';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { cn } from '@/lib/utils';

/** 一页拉多少 —— 设计稿是滚到底继续加载，一屏 12 行左右 */
const PAGE = 20;

/**
 * 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
 * ⚠️ **列表和抽屉共用这一个常量** —— 各写各的必然漂，而漂了主管只会以为某一处算错了。
 */
const IDLE_HEAVY = 0.2;

/**
 * 「还在跑的」判据 —— **时限还没到，或还有没人动过的单**。
 *
 * ⚠️ 这是**前端算的**，因为它是一个展示分组不是业务状态：
 *   服务端的 `status` 只有 confirmed/revoked，没有"跑没跑完"这个概念，
 *   而这个分组的意义纯粹是「哪几批还来得及补救」。
 * ⛔ 别为它去后端加一列 —— 口径一旦落库，改起来要迁移数据。
 */
function isRunning(b: AssignmentBrief, now: number): boolean {
  if (b.status === 'revoked') return false;
  return new Date(b.expiresAt).getTime() > now || b.inHand > 0;
}

/// 触达渠道中文。⚠️ 「电话」不在界面上标(默认值,每行都写就成噪音)—— 见调用处
const CHANNEL_ZH: Record<string, string> = {
  phone: '电话',
  wecom: '企微',
  sms: '短信',
  other: '其他',
};

/**
 * 通话记录的时间戳 —— 「8/6 14:32」。
 * ⚠️ 给**分钟**不是只给日期:同一天同一个客服可能打了好几条,只到天就分不出先后。
 */
function fmtWhen(iso: string) {
  const d = new Date(iso);
  const p = (n: number) => String(n).padStart(2, '0');
  return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}`;
}

/** 「跑了几天」—— 主管比批次之前必须先看年龄（跑三个月的天然比跑三天的好看） */
function ageDays(b: AssignmentBrief, now: number): number {
  return Math.max(0, Math.floor((now - new Date(b.createdAt).getTime()) / 86_400_000));
}

/** 人群名 —— 从 label 里截「日期 · 人群」那两段；label 是服务端唯一生成点 */
function cohortOf(b: AssignmentBrief): { when: string; cohort: string } {
  const parts = b.label.split(' · ');
  return { when: parts[0] ?? '', cohort: parts.slice(1, 3).join(' · ') || '（未记录人群）' };
}

export function BatchTracking({ clinicId }: { clinicId: string | null }) {
  const [items, setItems] = useState<AssignmentBrief[]>([]);
  const [total, setTotal] = useState(0);
  const [cursor, setCursor] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [scope, setScope] = useState<'running' | 'all'>('all');
  const [openId, setOpenId] = useState<string | null>(null);
  const now = useMemo(() => Date.now(), [items]);

  /**
   * 🔴 **在途守卫必须是 ref，⛔ 不能靠 `loading` 这个 state**（2026-08-07 实测）。
   *
   * IntersectionObserver 在滚动时会**连续回调**，而 `loading` 是 effect 建立那一刻的
   * 快照 —— 它挡不住同一个 observer 的第二、第三次触发。结果是同一个 `cursor`
   * 被追加了两遍，`items` 里出现重复批次 → React 报
   * 「Encountered two children with the same key」，界面上那一批也真的画了两行。
   * ⛔ 别改成把 `loading` 加进 IO 回调的闭包 —— 闭包捕获的还是旧值，一样挡不住。
   */
  const inFlight = useRef(false);

  const load = useCallback(async (before?: string) => {
    if (inFlight.current) return;
    inFlight.current = true;
    setLoading(true);
    setError(null);
    try {
      const r = await assignmentsApi.list({ limit: PAGE, ...(before ? { before } : {}) });
      setTotal(r.total);
      setCursor(r.nextBefore);
      setItems((prev) => {
        if (!before) return r.items;
        /**
         * ⚠️ 追加时**按 id 去重**。游标分页的边界天然会重复:
         *   `createdAt` 同一毫秒建的两批(脚本批量分配就会),`< before` 那一刀
         *   切不干净。⛔ 别指望"守卫住了就不会重" —— 那是两回事。
         */
        const seen = new Set(prev.map((b) => b.id));
        return [...prev, ...r.items.filter((b) => !seen.has(b.id))];
      });
    } catch (e) {
      setError(e instanceof Error ? e.message : '加载失败');
    } finally {
      inFlight.current = false;
      setLoading(false);
    }
  }, []);

  /**
   * 首屏 + 换诊所 + **助手那边确认/撤销之后**(syncSeq)重新拉。
   *
   * 🔴 重拉必须**回到第一页**:`setItems([])` + `setCursor(null)` 一起做,
   *   ⛔ 不能只 `load()` —— 那会把新的一页**追加**到旧列表后面,
   *   而新分的批次排在最前,结果是它出现在了列表中段(游标分页的天然后果)。
   * ⚠️ 抽屉开着时也照刷:里面的明细自己按 openId 取,不受这里影响。
   */
  const syncSeq = useAssignmentSyncStore((s) => s.seq);
  useEffect(() => {
    setItems([]);
    setCursor(null);
    void load();
  }, [clinicId, load, syncSeq]);

  /**
   * 滚到底继续加载。
   * ⚠️ 用 IntersectionObserver 而不是 scroll 事件 —— 后者在这种内层滚动容器里
   *   要自己算 scrollHeight，改布局就失效。
   */
  /**
   * 滚到底继续加载 —— **监听滚动容器的 scroll**，⛔ 不用 IntersectionObserver。
   *
   * 🔴 两条都踩过（2026-08-07）：
   *   ① 哨兵是零高度 div → IO 对**零面积目标根本不回调**，连 observe() 后那次初始
   *      回调都没有。表现是"滚到底永远不加载"，而控制台一个字不报。
   *   ② 给了高度之后仍不触发 —— 实测在内置浏览器面板里 **IO 整个不工作**
   *      （挂到 `<h1>` 上也不回调）。那是环境问题，但它说明一件事：
   *      **这条路没法在我们的验证环境里证伪**，出问题只能靠用户反馈。
   * ⇒ 换成 `scrollTop + clientHeight >= scrollHeight - 阈值`：没有任何隐式前提，
   *   在哪儿都成立，也能在控制台一行验完。
   * ⚠️ 仍要 `inFlight` 守卫：scroll 事件比 IO 密集得多，不挡住会连发好几页。
   */
  const scroller = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = scroller.current;
    if (!el || !cursor || scope === 'running') return;
    const onScroll = () => {
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 160) void load(cursor);
    };
    el.addEventListener('scroll', onScroll, { passive: true });
    // 首屏没撑满时也要能继续拉（否则永远等不到一次 scroll）
    onScroll();
    return () => el.removeEventListener('scroll', onScroll);
  }, [cursor, scope, load]);

  const running = items.filter((b) => isRunning(b, now));
  const shown = scope === 'running' ? running : items;

  /**
   * 按月分组（「还在跑的」单独一组排最前）。
   * ⚠️ 分组只在「全部」视图里做 —— 「还在跑的」本身就是一组，再按月切没有意义。
   */
  const groups = useMemo(() => {
    const out: BatchGroupData[] = [];
    if (scope === 'running') {
      return [
        { key: '还在跑的', note: '时限未到、或还有没人动过的单', past: false, rows: shown },
      ];
    }
    const runIds = new Set(running.map((b) => b.id));
    const run = shown.filter((b) => runIds.has(b.id));
    if (run.length)
      out.push({ key: '还在跑的', note: '时限未到、或还有没人动过的单', past: false, rows: run });
    let lastKey = '';
    for (const b of shown) {
      if (runIds.has(b.id)) continue;
      const d = new Date(b.createdAt);
      const key = `${d.getMonth() + 1} 月`;
      if (key !== lastKey) {
        out.push({ key, note: '已经结束，只作回顾', past: true, rows: [] });
        lastKey = key;
      }
      out[out.length - 1]!.rows.push(b);
    }
    return out.filter((g) => g.rows.length > 0);
  }, [shown, running, scope]);

  return (
    <div className="relative flex min-h-0 w-full flex-col overflow-hidden rounded-lg border bg-white">
      <div className="flex flex-none flex-wrap items-center gap-x-2.5 gap-y-1 border-b px-3.5 py-2.5">
        <span className="text-[13.5px] font-semibold text-slate-900">我分的批次</span>
        <span className="text-[11px] text-slate-400">点一行看这批具体怎么样</span>
        <span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5">
          <button
            type="button"
            onClick={() => setScope('running')}
            className={cn(
              'nums rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
              scope === 'running'
                ? 'bg-white text-slate-900 shadow-sm'
                : 'text-slate-500 hover:text-slate-700',
            )}
          >
            还在跑的 {running.length}
          </button>
          <button
            type="button"
            onClick={() => setScope('all')}
            className={cn(
              'nums rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
              scope === 'all'
                ? 'bg-white text-slate-900 shadow-sm'
                : 'text-slate-500 hover:text-slate-700',
            )}
          >
            全部 {total}
          </button>
        </span>
      </div>

      <div ref={scroller} className="min-h-0 flex-1 overflow-auto">
        {error ? (
          <p className="px-3.5 py-6 text-center text-[12px] text-rose-600">{error}</p>
        ) : items.length === 0 && loading ? (
          <div className="space-y-2 p-3.5">
            {Array.from({ length: 8 }).map((_, i) => (
              <div key={i} className="h-6 animate-pulse rounded bg-slate-100" />
            ))}
          </div>
        ) : items.length === 0 ? (
          <p className="px-3.5 py-10 text-center text-[12px] text-slate-400">还没有分过批次</p>
        ) : (
          <table className="w-full border-collapse text-[12.5px] text-slate-700">
            <thead>
              <tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]">
                <th className="px-3.5 py-1.5 text-left text-[11px] font-medium text-slate-500">批次</th>
                {['条数', '已处置', '没动', '退回', '到期回池', '约上'].map((h) => (
                  <th
                    key={h}
                    className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
                  >
                    {h}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {groups.map((g) => (
                <BatchGroup
                  key={g.key}
                  group={g}
                  now={now}
                  openId={openId}
                  onOpen={setOpenId}
                />
              ))}
            </tbody>
          </table>
        )}
        {items.length > 0 && (
          <div className="flex items-center gap-2 px-3.5 pb-1.5 pt-2.5">
            <span className="h-px flex-1 bg-slate-50" />
            <span className="nums text-[10.5px] text-slate-400">
              {scope === 'running'
                ? `还在跑的 ${running.length} 批已全部显示`
                : cursor
                  ? `已显示 ${items.length} / ${total} 批 · 往下滚动继续加载`
                  : `已显示全部 ${items.length} 批`}
            </span>
            <span className="h-px flex-1 bg-slate-50" />
          </div>
        )}
      </div>

      {openId && <BatchDrawer id={openId} onClose={() => setOpenId(null)} />}
    </div>
  );
}

type BatchGroupData = { key: string; note: string; past: boolean; rows: AssignmentBrief[] };

/**
 * 「还在跑的」与「历史」两套配色（2026-08-11 产品：两者要在**颜色上**分得开）。
 *
 * ⭐ 分法是「**主色 + 满饱和** vs 灰底 + 褪色」：
 *   在跑的那几批带一条主色竖轨、白底、数字保持原来的语义色（绿=已处置、红=退回、
 *   琥珀=没动/到期）—— 它们是主管**还来得及动手**的；
 *   历史批次退成灰底 + 同色相的低饱和，语义色仍在（还看得出哪列是什么），但整块往后退。
 * ⛔ 别把历史行做成纯灰（把语义色一起抹掉）：那样「退回 12」和「约上 12」看起来一样，
 *   而回顾恰恰就是在比这几列。
 * ⛔ 也别只靠那条竖轨 —— 一条 2px 的线滚起来看不见，底色才是扫视时抓得到的。
 *
 * ⚠️ `past` 的竖轨是**透明但仍占 2px**（`border-l-transparent`）—— 少了它，历史行会
 *   比在跑的行少 2px 左内缩，两组的首列对不齐。⛔ 别图省事删成没有 border-l。
 */
const TONE = {
  live: {
    rail: 'border-l-2 border-l-brand-400',
    row: 'bg-white hover:bg-slate-50',
    when: 'text-slate-500',
    cohort: 'text-slate-900',
    meta: 'text-slate-400',
    planned: 'text-slate-700',
    handled: 'text-emerald-700',
    idle: 'text-slate-500',
    idleHeavy: 'font-semibold text-amber-700',
    released: 'text-rose-600',
    expired: 'text-amber-700',
    booked: 'text-slate-900',
    // 组标题也带上主色（浅底 + 深字）—— 竖轨太细，标题这一条才是扫视时先看到的
    head: 'bg-brand-50',
    headKey: 'text-brand-800',
  },
  past: {
    rail: 'border-l-2 border-l-transparent',
    row: 'bg-slate-50/80 hover:bg-slate-100',
    when: 'text-slate-400',
    cohort: 'text-slate-500',
    meta: 'text-slate-400/80',
    planned: 'text-slate-400',
    handled: 'text-emerald-800/55',
    idle: 'text-slate-400',
    idleHeavy: 'font-medium text-amber-800/60',
    released: 'text-rose-800/55',
    expired: 'text-amber-800/55',
    booked: 'text-slate-500',
    head: 'bg-slate-100/70',
    headKey: 'text-slate-500',
  },
} as const;

function BatchGroup({
  group,
  now,
  openId,
  onOpen,
}: {
  group: BatchGroupData;
  now: number;
  openId: string | null;
  onOpen: (id: string) => void;
}) {
  const t = group.past ? TONE.past : TONE.live;
  return (
    <>
      <tr>
        <td colSpan={7} className={cn('border-t px-3.5 py-1.5', t.rail, t.head)}>
          <span className={cn('text-[11px] font-semibold', t.headKey)}>{group.key}</span>
          <span className="ml-1.5 text-[10.5px] text-slate-400">{group.note}</span>
        </td>
      </tr>
      {group.rows.map((b) => {
        const { when, cohort } = cohortOf(b);
        // 「没动」占比高的标出来 —— 那是本批最值得先看的一格
        const heavy = b.planned > 0 && b.inHand / b.planned > IDLE_HEAVY;
        return (
          <tr
            key={b.id}
            onClick={() => onOpen(b.id)}
            className={cn(
              'cursor-pointer border-t',
              t.row,
              // ⚠️ 选中态压在两套配色**之上** —— 抽屉开着的那一行必须一眼看出来，
              //   ⛔ 不能被历史行的灰底吃掉
              openId === b.id && 'bg-brand-50 hover:bg-brand-50',
            )}
          >
            <td className={cn(t.rail, 'px-3.5 py-2')}>
              <div className="flex items-baseline gap-2 whitespace-nowrap">
                <span className={cn('nums text-[11.5px]', t.when)}>{when}</span>
                <span className={cn('font-medium', t.cohort)}>{cohort}</span>
                <span className={cn('nums text-[11px]', t.meta)}>
                  跑了 {ageDays(b, now)} 天 · {b.agents} 位客服
                </span>
                {b.status === 'revoked' && (
                  <span className="text-[10.5px] text-slate-400">已撤销</span>
                )}
              </div>
            </td>
            <td className={cn('nums px-2.5 py-2 text-right', t.planned)}>{b.planned}</td>
            <td className={cn('nums px-2.5 py-2 text-right', t.handled)}>{b.handled}</td>
            <td className={cn('nums px-2.5 py-2 text-right', heavy ? t.idleHeavy : t.idle)}>
              {b.inHand}
            </td>
            <td className={cn('nums px-2.5 py-2 text-right', t.released)}>{b.released}</td>
            <td className={cn('nums px-2.5 py-2 text-right', t.expired)}>{b.expired}</td>
            <td className={cn('nums px-2.5 py-2 text-right', t.booked)}>{b.booked}</td>
          </tr>
        );
      })}
    </>
  );
}

/**
 * 单批下钻 —— 按客服拆 + 退回原因分布。
 *
 * ⚠️ 抽屉盖在**表格上**而不是整页：主管的上下文是"我在看这一列批次"，
 *   盖住整页他会失去位置感（设计稿也是这么做的 top:41px 起）。
 */
function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
  const [d, setD] = useState<AssignmentDetailResponse | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    setD(null);
    setError(null);
    assignmentsApi
      .detail(id)
      .then((r) => alive && setD(r))
      .catch((e) => alive && setError(e instanceof Error ? e.message : '加载失败'));
    return () => {
      alive = false;
    };
  }, [id]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);

  const maxReason = Math.max(1, ...(d?.releaseReasons ?? []).map((r) => r.n));

  return (
    <>
      {/*
        🔴 **z-20 不能省**（2026-08-07 实测）：表格的 `thead` 是 `sticky z-10`，
          抽屉不给层级的话，那一排列头（已处置 / 没动 / 退回 / 到期回池 / 约上）
          会**浮在抽屉标题上面**，跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了，
          实际是层级问题。⛔ 别去改 thead 的 sticky（那是列表滚动要用的）。
        ⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度：抽屉盖住表格但**不盖标题**，
          主管要始终看得见"我还在看批次列表"。
      */}
      <div
        className="absolute inset-x-0 bottom-0 top-[3.25rem] z-20 bg-slate-900/10"
        onClick={onClose}
      />
      <aside className="absolute bottom-0 right-0 top-[3.25rem] z-20 flex w-[26rem] max-w-full flex-col overflow-hidden border-l bg-white shadow-[-12px_0_24px_-6px_rgba(15,23,42,0.10)]">
        <div className="flex flex-none items-start gap-2.5 border-b bg-brand-50 px-3.5 py-3">
          <div className="min-w-0 flex-1">
            <div className="truncate text-[13px] font-semibold text-slate-900">
              {d?.label ?? '加载中…'}
            </div>
            {d && (
              <div className="nums mt-0.5 text-[11px] text-slate-600">
                已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 到期回池 {d.expired} ·
                约上 {d.booked}
              </div>
            )}
          </div>
          <button
            type="button"
            onClick={onClose}
            aria-label="关闭"
            className="flex-none rounded-md p-1 text-slate-500 hover:bg-brand-100 hover:text-brand-700"
          >
            <X className="h-4 w-4" />
          </button>
        </div>

        <div className="min-h-0 flex-1 overflow-auto px-3.5 pb-4 pt-3">
          {error ? (
            <p className="py-6 text-center text-[12px] text-rose-600">{error}</p>
          ) : !d ? (
            <div className="space-y-2">
              {Array.from({ length: 6 }).map((_, i) => (
                <div key={i} className="h-5 animate-pulse rounded bg-slate-100" />
              ))}
            </div>
          ) : (
            <>
              <div className="mb-1.5 text-[11.5px] font-semibold text-slate-700">按客服拆</div>
              <table className="w-full border-collapse text-[12px] text-slate-700">
                <thead>
                  <tr>
                    {['客服', '分到', '已处置', '没动', '退回'].map((h, i) => (
                      <th
                        key={h}
                        className={cn(
                          'py-1 text-[10.5px] font-medium text-slate-400',
                          i === 0 ? 'text-left' : 'px-1.5 text-right',
                        )}
                      >
                        {h}
                      </th>
                    ))}
                  </tr>
                </thead>
                <tbody>
                  {d.agentStats.map((a) => (
                    <tr key={a.userId} className="border-t">
                      <td className="py-1.5">{a.name ?? a.userId}</td>
                      <td className="nums px-1.5 py-1.5 text-right">{a.planned}</td>
                      <td className="nums px-1.5 py-1.5 text-right text-emerald-700">{a.done}</td>
                      {/* 🔴 判据必须与**外面那张表**一致（占比 > 20%），⛔ 不是"绝对值 > 4"：
                          实测「分到 1 / 没动 1」（100%）在抽屉里是灰的、在列表里是琥珀的 ——
                          同一件事两种颜色，主管会以为其中一处算错了。 */}
                      <td
                        className={cn(
                          'nums px-1.5 py-1.5 text-right',
                          a.planned > 0 && a.inHand / a.planned > IDLE_HEAVY
                            ? 'font-semibold text-amber-700'
                            : 'text-slate-500',
                        )}
                      >
                        {a.inHand}
                      </td>
                      <td className="nums py-1.5 text-right text-rose-600">{a.released}</td>
                    </tr>
                  ))}
                </tbody>
              </table>

              <div className="mt-4 text-[11.5px] font-semibold text-slate-700">退回原因分布</div>
              {/* 🔴 退回率两个分母都给 —— 只给百分比会把"没动"藏起来 */}
              <div className="nums mt-0.5 text-[10.5px] text-slate-400">
                退回 {d.released} / 已处置 {d.handled}
                {d.handled > 0 ? ` = ${((d.released / d.handled) * 100).toFixed(1)}%` : ''}，另有{' '}
                {d.inHand} 条没动
              </div>
              <div className="mt-2 flex flex-col gap-1.5">
                {(d.releaseReasons.length
                  ? d.releaseReasons
                  : (Object.keys(RELEASE_REASON_META) as ReleaseReason[])
                      .filter((k) => !RELEASE_REASON_META[k].hidden)
                      .map((k) => ({ reason: k, labelZh: RELEASE_REASON_META[k].labelZh, n: 0 }))
                ).map((r) => (
                  <div
                    key={r.reason}
                    className="grid grid-cols-[7rem_1fr_1.4rem] items-center gap-2"
                  >
                    <span className="truncate text-[11px] text-slate-600">{r.labelZh}</span>
                    <span className="h-1.5 overflow-hidden rounded-full bg-slate-100">
                      <span
                        className="block h-full rounded-full bg-brand-600"
                        style={{ width: `${Math.round((r.n / maxReason) * 100)}%` }}
                      />
                    </span>
                    <span className="nums text-right text-[11px] text-slate-500">{r.n}</span>
                  </div>
                ))}
              </div>

              {/* 🔴 这里原来贴的是 `d.outcomes.note` 那句成品句子,2026-08-08 随字段一起删。
                  它是写给**模型**看的(带 `**` 和 ⛔ 的指令),在界面上是原样渲染的乱码;
                  真正给人看的成效数在下面这四个桶里。⛔ 别再把模型指令当界面文案用。 */}
              <div className="mt-4 text-[11.5px] font-semibold text-slate-700">通话成效</div>
              {/* ⚠️ 四个桶**穷尽**(success + failed + keep + noOutcome === 条数,回归里锁着)——
                    所以四个都要摆出来,少一个就对不上数(T14)。
                  🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前三个分开、
                    并且是唯一上色的:主管先要看的就是它。 */}
              <div className="mt-1.5 grid grid-cols-4 gap-2 text-center">
                {(
                  [
                    { k: '成功', n: d.outcomes.success, c: 'text-emerald-700' },
                    { k: '不成功', n: d.outcomes.failed, c: 'text-slate-600' },
                    { k: '没进展', n: d.outcomes.keep, c: 'text-slate-600' },
                    {
                      k: '没有结果',
                      n: d.outcomes.noOutcome,
                      c: d.outcomes.noOutcome > 0 ? 'text-amber-700' : 'text-slate-300',
                    },
                  ] as const
                ).map((o) => (
                  <div key={o.k} className="rounded-lg bg-slate-50 py-1.5">
                    <div className={cn('nums text-[15px] font-semibold', o.c)}>{o.n}</div>
                    <div className="text-[10.5px] text-slate-500">{o.k}</div>
                  </div>
                ))}
              </div>
              {/* ⚠️ 「成功」含**约定下次回访** —— 不写这句主管会读成"成交了这么多" */}
              <div className="mt-1.5 text-[10.5px] leading-relaxed text-slate-400">
                「成功」含约定下次回访,不等于都成交了;「没有结果」是这批里一次都没打/没记的。
              </div>

              {/*
                🔴 **通话记录** —— 客服手写的电话纪要,2026-08-08 之前**全线读不到**:
                  `plan_executions.notes` 写进去就没有任何界面读回来过
                  (接口一直在,前端没调、助手也没工具)。主管看到「不成功 1」
                  只能知道有这么个数,问不出"哪个患者、为什么"。
                ⚠️ 条数与上面四个桶对得上:records.length === 成功 + 不成功 + 没进展
                  (「没有结果」的人压根没有执行行)—— ⛔ 别在这里补空行凑数。
                ⚠️ 结果的颜色跟着 `group` 走,与上面四个桶同一套,⛔ 别另配一版。
              */}
              {d.outcomes.records.length > 0 && (
                <>
                  <div className="mt-4 flex items-baseline gap-2">
                    <span className="text-[11.5px] font-semibold text-slate-700">通话记录</span>
                    <span className="text-[10.5px] text-slate-400">
                      {d.outcomes.records.length} 条 · 每条单只取最近一次
                    </span>
                  </div>
                  <ul className="mt-1.5 max-h-72 space-y-1 overflow-y-auto overscroll-contain">
                    {d.outcomes.records.map((r) => (
                      <li key={r.planId} className="rounded-lg bg-slate-50 px-2.5 py-1.5">
                        <div className="flex items-baseline gap-1.5">
                          <span className="truncate text-[11.5px] font-medium text-slate-700">
                            {r.patientName ?? '—'}
                          </span>
                          <span
                            className={cn(
                              'flex-none text-[10.5px]',
                              r.group === 'close'
                                ? 'text-emerald-700'
                                : r.group === 'give_up'
                                  ? 'text-rose-600'
                                  : 'text-slate-500',
                            )}
                          >
                            {r.labelZh}
                          </span>
                          {/* ⚠️ 渠道只在**不是电话**时标 —— 电话是默认,每行都写就成了噪音 */}
                          {r.channel !== 'phone' && (
                            <span className="flex-none rounded bg-slate-200/70 px-1 text-[10px] text-slate-600">
                              {CHANNEL_ZH[r.channel] ?? r.channel}
                            </span>
                          )}
                          <span className="ml-auto flex-none text-[10.5px] text-slate-400">
                            {r.operatorName ?? r.operatorUserId} · {fmtWhen(r.at)}
                          </span>
                        </div>
                        {/*
                          🔴 **客服勾的子选项也要摆出来**(2026-08-08 产品追问)。
                            `outcome` 只是一个字段,表单里还有放弃原因(多选)、
                            「判断不对」选的治疗、约的回访日期 —— 只显示 outcome
                            等于把他填的一半东西藏了。
                          ⚠️ 三者**互斥地出现**(各自绑定不同 outcome),所以只渲染有值的那个,
                            ⛔ 别给空的留占位行。
                        */}
                        {(r.abandonReasons.length > 0 ||
                          r.inaccurateTreatments.length > 0 ||
                          r.scheduledNextAt) && (
                          <div className="mt-1 flex flex-wrap items-center gap-1">
                            {r.abandonReasons.map((a) => (
                              <span
                                key={a.reason}
                                className="rounded bg-rose-50 px-1.5 py-0.5 text-[10.5px] text-rose-700"
                              >
                                {a.labelZh}
                              </span>
                            ))}
                            {/* ⚠️ 「判断不对」是**客服说算法判错了** —— 调算法要看的就是它,
                                所以单独标出治疗名,⛔ 别跟放弃原因混成一色 */}
                            {r.inaccurateTreatments.map((t) => (
                              <span
                                key={t.code}
                                className="rounded bg-amber-50 px-1.5 py-0.5 text-[10.5px] text-amber-800"
                              >
                                判断不对:{t.labelZh}
                              </span>
                            ))}
                            {r.scheduledNextAt && (
                              <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10.5px] text-emerald-700">
                                约 {fmtWhen(r.scheduledNextAt)}
                              </span>
                            )}
                          </div>
                        )}
                        {/*
                          ⭐ **纪要空着本身是信息**:结果填了、一个字没写,说明只点了个选项。
                          ⛔ 别把这行整个省掉 —— 省掉之后"写了但很短"和"根本没写"看起来一样。
                          ⚠️ `whitespace-pre-wrap`:客服是分行写的,压成一行就读不出条理。
                        */}
                        <div
                          className={cn(
                            'mt-0.5 whitespace-pre-wrap text-[11px] leading-relaxed',
                            r.notes ? 'text-slate-600' : 'italic text-slate-300',
                          )}
                        >
                          {r.notes ?? '没留纪要'}
                        </div>
                      </li>
                    ))}
                  </ul>
                  {/* ⛔ 截断不许不说 —— 主管会以为"就这些" */}
                  {d.outcomes.recordsTruncated && (
                    <div className="mt-1 text-[10.5px] text-amber-700">
                      只显示了最近 {d.outcomes.records.length} 条,这批还有更多通话记录。
                    </div>
                  )}
                </>
              )}
            </>
          )}
        </div>
      </aside>
    </>
  );
}
