'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
  potentialTreatmentItemName,
  AssignStrategy,
  REVOKE_WINDOW_MINUTES,
  Permission,
  sheetSnapshotLine,
  type AgentOverride,
  type AssignmentProposal,
  type ProposedItem,
  type RevokeAssignmentResponse,
  type SheetEditOp,
  type SheetSnapshot,
  type Signal,
} from '@pac/types';
import { Can } from '@/components/can';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { CopyableName, DaySelect } from './sheet-primitives';
import { cn, formatGender } from '@/lib/utils';

/**
 * 全景确认单 —— 主管**一次确认**就落库的那张卡片。
 *
 * ⚠️ 必须是**原生 React 组件**,不能用 `render_artifact`:
 * artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`,
 * **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
 *
 * ── 卡片上能做什么(2026-08-03 产品定)──────────────────────────
 * 四件**局部**操作,都不需要重新圈人、也不需要重跑落人算法:
 *   ① 逐条改时限(1-7 天)   ② 删掉某一条
 *   ③ 把某一条拖给别的客服  ④ 移除某个客服(连同他名下的条目)
 * ⛔ 换人群、改批次人数、按条件重新收敛 —— 一律回对话让助手重出一版。
 *   判据很简单:**要不要重跑服务端算法**。要,就回对话;不要,才放到卡片上。
 *
 * ⚠️ 这不违 T13「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
 *   上面四件事都是对**已经算好的结果**做局部修正,不产生新的人群。
 *
 * ── 布局 ────────────────────────────────────────────────
 * 助手窗只有 480px 宽(2026-08-11 从 400 调宽;最大化后更宽),所以**不用 table**(横向撑爆),用卡片列表;
 * 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
 */

/** 卡片本地状态下,一条召回计划的**生效**形态(叠加了主管的改动) */
interface EffectiveItem extends ProposedItem {
  /** 生效的经办人(拖拽后可能不是提案里的那个) */
  assignee: string;
  /** 生效时限(天) */
  days: number;
  /** 是否被主管拖到了别人名下 —— 落库时策略要改成 manual */
  moved: boolean;
}

/**
 * 精调表落库形态 = 提案带来的名额上限部分(原样带走)。
 *
 * ⚠️ 时限**不再进这里** —— 它已经细到"每一条召回计划"一级,再往 agentOverrides
 *   里塞一个"按客服的时限"就是两套口径打架(同一个人两处不同的值,听谁的?)。
 * ⚠️ 名额上限必须原样带走:少带一次,主管上次设的「李莉这批最多 5 条」
 *   就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
 */
/**
 * 助手编辑动作的中文名 —— 只用在**拒绝**的那句话里(「已经确认了,『改时限』改不了」)。
 * ⚠️ 拒绝必须说清拒的是**哪一件事**:一句笼统的"改不了"会让模型下一轮换个说法再试一遍。
 */
const OP_ZH: Record<SheetEditOp['action'], string> = {
  assign: '改派',
  remove: '移出本批',
  set_expiry: '改时限',
  set_benefit: '改福利',
};

/**
 * 服务端成品句子里的 `**加粗**` —— 只认这一个标记,⛔ 不引 Markdown 渲染器。
 *
 * ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote),里面混着患者和客服姓名。
 *   上一个完整的 Markdown 渲染器进来,姓名里的 `*` `_` `#` 就会被当成语法吃掉 ——
 *   而那是主管要照着念的名字。加粗是唯一需要的表达,自己切最省事也最安全。
 */
function Emphasized({ text }: { text: string }) {
  return (
    <>
      {text.split('**').map((seg, i) =>
        i % 2 === 1 ? (
          <strong key={i} className="font-semibold">
            {seg}
          </strong>
        ) : (
          <span key={i}>{seg}</span>
        ),
      )}
    </>
  );
}

function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string, AgentOverride> {
  const out: Record<string, AgentOverride> = {};
  for (const [userId, o] of Object.entries(fromSheet)) {
    if (o.maxThisBatch != null) out[userId] = { maxThisBatch: o.maxThisBatch };
  }
  return out;
}


export function AssignmentConfirmSheet({
  requestId,
  sheet,
  state,
  assignmentId,
  onConfirmed,
  edits,
  onEditApplied,
  onSheetReplaced,
  onIntent,
  placedKeys,
  onCompose,
  streaming,
  onLiveSignalsChange,
  busyIntent,
}: {
  requestId: string;
  sheet: AssignmentProposal;
  /** 草稿状态机，见 use-assistant-chat 的 Block 注释。`superseded` = 已被新版顶掉 */
  state: 'active' | 'superseded' | 'confirmed' | 'cancelled';
  assignmentId?: string;
  /**
   * 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆)。
   * @param summary       界面那份(主管看的一句事实)
   * @param modelFacts    喂模型那份 —— **结构化事实**,带完整批次 id,界面不显示
   *
   * 🔴 曾有第四个参数 `postConfirm`（确认后由卡片摆的引导节点，唯一的用户是
   *   「这批还没带福利」）—— **删于 2026-08-15**：确认单里不做福利引导。
   *   ⛔ 别加回来：确认之后要说什么归模型（提示词 ④），卡片只管这一版本身。
   */
  onConfirmed: (assignmentId: string, summary: string, modelFacts?: string) => void;
  /**
   * 助手下发的语义编辑指令队列(edit_assignment_sheet)。
   * ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
   */
  edits?: { seq: number; ops: SheetEditOp[] }[];
  /** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
  /**
   * 改单结果进对话。
   * ⚠️ 两个参数**不一样**:`summary` 是主管看的(改了什么 —— 增量才是新闻),
   *   `modelText` 另外缀一行**当前值**给模型(它看不见卡片,只有这一行)。
   *   ⛔ 别把当前值也显示出来:那几个数就在他眼前的卡片上,写两遍是读两遍。
   */
  onEditApplied?: (summary: string, modelText?: string) => void;
  /**
   * 「重新排一版」返回的新提案 —— 由外层把消息流里那个 block 换掉。
   * ⚠️ 换的是**整张卡**:人群没变、人换了,主管在旧卡上做的删/改派对新名单没有意义。
   */
  onSheetReplaced?: (next: AssignmentProposal) => void;
  /**
   * ⭐ 引导节点上的选项被点了 —— **按钮与助手共用同一组 intent**。
   *
   * 卡片自己只处理两件它本来就会做的事（重排一版 / 改时限），其余**原样抛给上层**：
   * 上层要么把它翻成 `SheetEditOp[]` 压进和助手同一条 edits 队列，要么交给助手重出一版。
   * ⛔ 别在这里为每个 intent 各写一套执行 —— 那就等于两条路各做各的，必然漂。
   */
  onIntent?: (intent: string, args?: Record<string, unknown>) => void;
  /**
   * 已经被模型插进正文的引导节点（`show_guidance`）—— 卡片上不再重复显示。
   *
   * ⭐ 反过来说：**模型没插的就留在卡片上**。所以卡片天然是兜底面 ——
   *   模型漏调工具，主管照样看得见那几条，⛔ 不会静默消失。
   */
  placedKeys?: string[];
  /** 兜底 —— 把焦点落到底部输入框（传空串 = 不预填，见操作说明那一行） */
  onCompose?: (draft: string) => void;
  /** 这一轮还在写 —— 卡内引导节点兜底要等说完再显示（见 settledSignals） */
  streaming?: boolean;
  /** 上报「主语还在」的节点 key —— 正文里那份据此决定还渲不渲染（见 liveSignals） */
  onLiveSignalsChange?: (requestId: string, keys: string[]) => void;
  /**
   * 正在执行的 intent（转圈 + 整排禁用）。
   * ⚠️ 由**上层**持有：会花时间的动作（重排 / 让模型重出一版）都不在卡片里执行了，
   *   ⛔ 卡片自己留一份 state 只会永远是 null。
   */
  busyIntent?: string | null;
}) {
  /**
   * 批次默认时限 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
   * 改它 = 把所有**没有单独设过**的条目一起改;单独设过的不动(下面 dayByPlan 优先)。
   */
  /**
   * 整张单**默认收起**（2026-08-13 产品定）—— 展开只露汇总行 + 操作说明两行。
   *
   * 🔴 客服清单动辄几十行、待分配上百人，一张单能顶掉助手窗好几屏；
   *   而主管多数时候只看两个数（排好几个、卡住几个）就点确认了。
   * ⚠️ 收起的**只有名单**。汇总行、操作说明、引导节点、确认按钮都常显 ——
   *   ⛔ 引导节点藏不得（那是"有人会被漏掉"的唯一提示），
   *   ⛔ 确认按钮更不能藏（主操作躲在展开一步后面）。
   */
  const [bodyOpen, setBodyOpen] = useState(false);
  const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays);
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [open, setOpen] = useState<Set<string>>(new Set());

  /// 逐条时限覆盖(planId → 天)。⚠️ 时限**落到每一条召回计划**,不是按客服
  const [dayByPlan, setDayByPlan] = useState<Record<string, number>>({});
  /// 拖拽改派(planId → 新经办人)。落库时这些条的策略改成 manual(主管指定)
  const [moveByPlan, setMoveByPlan] = useState<Record<string, string>>({});
  /// 主管删掉的条目
  const [dropped, setDropped] = useState<Set<string>>(new Set());
  /// 待确认移除的客服(AlertDialog 用;移除 = 连同他名下条目一起移出本批)
  const [pendingRemove, setPendingRemove] = useState<string | null>(null);
  /// 拖拽悬停中的目标客服(高亮用)
  const [dragOver, setDragOver] = useState<string | null>(null);
  /**
   * 批次福利(T4)—— 挂在**批次**上不挂个人,是**话术勾子 + 归因标签**。
   * v1 不核销、不接宿主卡券:落库进 `attributes.benefit.text`,生成话术时作为事实输入进 prompt。
   * ⚠️ 卡片上**只读**:文案由主管口述、助手写进来(set_benefit)——
   *   在 480px 的卡片里放一个自由文本框,主管十有八九会写成一段带承诺的话,
   *   而福利话术有硬护栏(不得追加条件/期限/承诺),那道闸在生成侧,不在这里。
   */
  const [benefit, setBenefit] = useState<string>('');
  /**
   * ── 撤销整批(限时 30 分钟)────────────────────────────────
   *
   * ⚠️ 语义是「**手滑 / 分错人**的补救」,不是"改主意重新调度" ——
   *   改主意应该走「客服退回」,那条路有完整的原因记录(T7)。所以限时。
   * ⚠️ 它是 **UX 摩擦不是安全边界**:主管本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
   *   30 分钟后照样能逐条收回来。⛔ 别基于"过了窗口就锁死了"去设计别的东西。
   * ⚠️ 窗口按**确认那一刻**算(confirmedAt),⛔ 不能用提案生成时间:
   *   主管可能盯着确认单改了二十分钟才点确认,那二十分钟不该算进他的补救窗口。
   */
  /// 「待分配」是否展开。⭐ **默认展开** —— 它的存在就是为了引起注意,默认藏起来等于白做
  const [pendingOpen, setPendingOpen] = useState(true);
  /// 「重新排一版」进行中(按钮转圈,防连点)
  const [confirmedAt, setConfirmedAt] = useState<number | null>(null);
  const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
  const [revoking, setRevoking] = useState(false);
  const [confirmRevoke, setConfirmRevoke] = useState(false);
  /// 每 30 秒重算一次窗口(到点按钮自己消失,⛔ 别让主管点了才被服务端拒)
  const [, setRevokeTick] = useState(0);
  useEffect(() => {
    if (!confirmedAt || revoked) return;
    const i = setInterval(() => setRevokeTick((t) => t + 1), 30_000);
    return () => clearInterval(i);
  }, [confirmedAt, revoked]);
  const canRevoke =
    assignmentId != null &&
    confirmedAt != null &&
    Date.now() - confirmedAt < REVOKE_WINDOW_MINUTES * 60_000;

  const doRevoke = async () => {
    if (!assignmentId) return;
    setRevoking(true);
    try {
      const res = await assignmentsApi.revoke(assignmentId);
      setRevoked(res);
      setConfirmRevoke(false);
      // ⭐ 撤销同样要让工作台重拉 —— 那一批的条数、每个人的在手都变了(见 assignment-sync-store)
      useAssignmentSyncStore.getState().notifyChanged();
      // ⭐ 结果**原话**进对话:note 是服务端拼的成品句子(含"已看过的没收回"那半句),
      //   既给主管看,也补进模型上下文 —— 否则它下一轮会说"整批都撤回了"
      echo(res.note);
    } catch (e) {
      setError(e instanceof Error ? e.message : '撤销失败');
      setConfirmRevoke(false);
    } finally {
      setRevoking(false);
    }
  };
  /**
   * 🔴 拖拽时的**自动滚动**。
   *
   * 助手窗里一屏只放得下三四个客服,而 HTML5 DnD **不会**自己滚容器 ——
   * 主管拖着一条想丢给下面的人,拖到边缘就卡住了,只能先滚一段再拖,基本等于不可用。
   * 这里在拖动过程中监听指针的 Y 位置:进入上/下 `EDGE` 像素带就按 rAF 持续滚。
   * ⚠️ 滚的是**离条目最近的可滚动祖先**(助手窗的消息区),不是 window ——
   *   写死 window 在最大化/非最大化两种形态下会滚错东西。
   */
  const scrollerRef = useRef<HTMLElement | null>(null);
  const autoScroll = useRef<{ dir: number; raf: number } | null>(null);
  const stopAutoScroll = () => {
    if (autoScroll.current) cancelAnimationFrame(autoScroll.current.raf);
    autoScroll.current = null;
  };
  const onDragOverAnywhere = (e: React.DragEvent) => {
    const sc = scrollerRef.current;
    if (!sc) return;
    const r = sc.getBoundingClientRect();
    const EDGE = 56; // 边缘感应带
    const dir = e.clientY < r.top + EDGE ? -1 : e.clientY > r.bottom - EDGE ? 1 : 0;
    if (dir === 0) return stopAutoScroll();
    if (autoScroll.current?.dir === dir) return; // 同方向already在滚
    stopAutoScroll();
    const step = () => {
      sc.scrollTop += dir * 12;
      autoScroll.current = { dir, raf: requestAnimationFrame(step) };
    };
    autoScroll.current = { dir, raf: requestAnimationFrame(step) };
  };

  const done = state === 'confirmed';
  /// 助手撤的整批(state 由 tool_result 切过来);本地撤的走 revoked
  const cancelled = state === 'cancelled';
  /**
   * ⭐ 已被新版顶掉 —— 主管让助手重排了一版,这张当场作废。
   * 🔴 **必须显式标注 + 禁用确认**,⛔ 不许只是变灰、更不许消失：
   *   两张都能点确认的卡摆在一起，点错就是分错一批人;
   *   而直接消失又会让他以为自己看错了、或者系统吞了东西。
   */
  const superseded = state === 'superseded';
  const readOnly = done || cancelled || superseded || submitting;

  /**
   * 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时限。
   * ⭐ 所有展示都从这里派生(条数、按客服分组、确认按钮上的数字),
   *   ⛔ 别再直接用 `sheet.byAgent` —— 它是**提案时**的分组,拖过一条就对不上了。
   */
  const items = useMemo<EffectiveItem[]>(
    () => [
      ...sheet.items
        .filter((i) => !dropped.has(i.planId))
        .map((i) => ({
          ...i,
          assignee: moveByPlan[i.planId] ?? i.assigneeUserId,
          days: dayByPlan[i.planId] ?? expiresInDays,
          moved: moveByPlan[i.planId] != null && moveByPlan[i.planId] !== i.assigneeUserId,
        })),
      /**
       * ⭐ 「待分配」里**被主管拖给某人**的那几条 —— 从这一刻起它们就是正常条目。
       *
       * ⚠️ 没拖过的**不进来**:待分配的语义就是"没有经办人",不处理就不落库。
       *   ⛔ 别在这里给个默认经办人兜底 —— 那等于把刚拿掉的自动改派偷偷加回来。
       * ⚠️ 一律标 `MANUAL`:这是**主管的决定**,不是算法的。事后反推「专属 vs 铺平」
       *   效果差异时,把人的决定混进算法策略里会直接污染结论(T20)。
       */
      ...(sheet.pending ?? [])
        .filter((p) => !dropped.has(p.planId) && moveByPlan[p.planId] != null)
        .map((p) => ({
          ...p,
          assigneeUserId: moveByPlan[p.planId]!,
          assignStrategy: AssignStrategy.MANUAL,
          assignee: moveByPlan[p.planId]!,
          days: dayByPlan[p.planId] ?? expiresInDays,
          moved: true,
        })),
    ],
    [
      sheet.items,
      sheet.pending,
      dropped,
      moveByPlan,
      dayByPlan,
      expiresInDays,
    ],
  );

  /**
   * 还留在「待分配」里的 —— 主管**还没处置**的那几条(既没拖给谁,也没移出)。
   * ⚠️ 这一组的存在本身就是要**引起注意**:确认时它们不会落库,不看就等于放弃这几个人。
   */
  const pendingList = useMemo(
    () =>
      (sheet.pending ?? []).filter((p) => !dropped.has(p.planId) && moveByPlan[p.planId] == null),
    [sheet.pending, dropped, moveByPlan],
  );

  /** 按客服分组(顺序沿用提案里的客服顺序;拖过去的新客服排在后面) */
  const groups = useMemo(() => {
    const byUser = new Map<string, EffectiveItem[]>();
    for (const it of items) {
      const arr = byUser.get(it.assignee) ?? [];
      arr.push(it);
      byUser.set(it.assignee, arr);
    }
    const order = [
      ...sheet.byAgent.map((a) => a.userId).filter((u) => byUser.has(u)),
      ...[...byUser.keys()].filter((u) => !sheet.byAgent.some((a) => a.userId === u)),
    ];
    /**
     * 🔴 姓名 / 在手量必须**同时查 byAgent 和 skippedAgents**(2026-08-06 实测)。
     *
     * byAgent 只含**提案时就分到条目**的人。主管把一个人拖给(或让助手分给)
     * 「本批未分到」里的客服后,那位客服在 byAgent 里查不到 →
     * 姓名回落成 id 前缀、在手回落成 0,界面显示成「**#678 1 条(在手 0 → 1)**」。
     * 而王锐实际在手 56 —— 一个真人被显示成一串 id,在手量还是错的(T14:证据要看得懂)。
     * ⛔ 别只补姓名不补在手量:那会变成「王锐 1 条(在手 0 → 1)」,更像数据坏了。
     */
    const nameOf = new Map<string, string | null>([
      ...sheet.byAgent.map((a) => [a.userId, a.name] as const),
      ...sheet.skippedAgents.map((a) => [a.userId, a.name] as const),
    ]);
    const inHandOf = new Map<string, number>([
      ...sheet.byAgent.map((a) => [a.userId, a.inHandBefore] as const),
      ...sheet.skippedAgents.map((a) => [a.userId, a.inHand] as const),
    ]);
    return order.map((userId) => ({
      userId,
      name: nameOf.get(userId) ?? null,
      inHandBefore: inHandOf.get(userId) ?? 0,
      list: byUser.get(userId)!,
    }));
  }, [items, sheet.byAgent, sheet.skippedAgents]);

  /**
   * 本批一条都没分到的客服 —— 拖过去之后他就有条目了,要从这行剔掉,
   * 否则同一个人既在上面成组、又在"未分到"里。
   * ⚠️ 用 memo 算一次:原来在 JSX 里写了两遍(`&&` 判空一次、渲染一次),
   *   每次重渲染跑两遍 O(a²);数量小不至于卡,但"同一个表达式写两遍"迟早会漂成两个口径。
   */
  const idleAgents = useMemo(
    () => sheet.skippedAgents.filter((a) => !groups.some((g) => g.userId === a.userId)),
    [sheet.skippedAgents, groups],
  );

  /**
   * 🔴 **这张单此刻的样子** —— 只算这一处，两个出口共用：
   *   ① 挂到 store，跟着每次 chat 请求发走 → 模型用 `get_current_sheet` 取；
   *   ② 每次回声把它压成一行缀进 `modelText`（见下面的 `echo`）。
   *
   * ⚠️ 存在的理由：确认之前这些数**只在这个组件里**，服务端查不到
   *   （沿革见 `SheetSnapshotSchema`）。此前模型只能把对话里几行「已更新…」自己累加，
   *   而**手动拖动、点 ×、改时限这几条路连回声都没有** —— 它连累加的材料都没有，
   *   于是一边说「时限 1 天」，卡片上写着 3 天，两边都不报错。
   */
  const snapshot = useMemo<SheetSnapshot>(
    () => ({
      requestId,
      confirmed: assignmentId != null,
      placed: items.length,
      agents: groups.length,
      pending: pendingList.length,
      dropped: dropped.size,
      moved: items.filter((i) => i.moved).length,
      expiresInDays,
      rowExpiryOverrides: Object.keys(dayByPlan).length,
      benefit: benefit.trim() || null,
      byAgent: groups.map((g) => ({
        name: g.name ?? g.userId.slice(0, 8),
        count: g.list.length,
      })),
      // 🔴 人群条件原样带上 —— 少了它，模型重算时不知道这批是哪一格的人(见 schema 上那段)
      criteria: {
        potentialTreatment: sheet.potentialTreatment,
        temperature: sheet.temperature,
        narrowedBy: sheet.narrowedBy,
        candidateTotal: sheet.candidateTotal,
        batchSize: sheet.batchSize,
      },
    }),
    [
      requestId,
      assignmentId,
      items,
      groups,
      pendingList.length,
      dropped.size,
      expiresInDays,
      dayByPlan,
      benefit,
      sheet,
    ],
  );

  /**
   * 挂到 store 供发请求时取。
   * ⚠️ 卸载时**只清自己那份**（按 requestId 比对）—— 重出一版时新卡片先挂、
   *   旧卡片后卸载，不比对就会把现役那张一起清掉，而清掉之后模型只是"查不到"，
   *   ⛔ 不会报错。
   */
  useEffect(() => {
    useAssistantStore.getState().setSheetSnapshot(snapshot);
    return () => {
      const s = useAssistantStore.getState();
      if (s.sheetSnapshot?.requestId === snapshot.requestId) s.setSheetSnapshot(null);
    };
  }, [snapshot]);

  /**
   * 回声 —— **主管看增量，模型另外拿当前值**。
   *
   * ⚠️ 为什么要排队到 effect 里发：`setDropped` / `setMoveByPlan` 是 React state，
   *   在同一轮里读不到刚写的值（这个文件下面那条注释为同一件事栽过一次）。
   *   就地发的话，缀上去的「当前」是**上一帧**的数 —— 比不发更坏。
   * ⚠️ 用 seq 触发而不是只看 snapshot：有些动作不改变任何数字
   *   （拖回原来那个人），那时 snapshot 不变，排的队会永远卡着。
   */
  /// planId → 主管认得的那个名字。⛔ 回声里别报 planId：那是内部码，他看不懂、模型会念出来
  const zhPatient = useCallback(
    (planId: string) => {
      const hit =
        sheet.items.find((i) => i.planId === planId) ??
        (sheet.pending ?? []).find((p) => p.planId === planId);
      return hit?.patientName ?? `#${planId.slice(0, 8)}`;
    },
    [sheet.items, sheet.pending],
  );
  const echoQueue = useRef<string[]>([]);
  const [echoSeq, setEchoSeq] = useState(0);
  const snapRef = useRef(snapshot);
  snapRef.current = snapshot;
  const echo = useCallback((summary: string) => {
    echoQueue.current.push(summary);
    setEchoSeq((n) => n + 1);
  }, []);
  useEffect(() => {
    if (echoSeq === 0 || echoQueue.current.length === 0) return;
    const text = echoQueue.current.join('；');
    echoQueue.current = [];
    // ⛔ 当前值只进 modelText：这几个数卡片上就在主管眼前，界面再写一行是读两遍
    onEditApplied?.(text, `${text}\n${sheetSnapshotLine(snapRef.current)}`);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [echoSeq]);

  /**
   * @param benefitNow **这一次**要挂的福利文案。
   * ⚠️ 走参数、⛔ 不读 `benefit` 这个 state：确认前那层浮层是「边填边确认」——
   *   `setBenefit(v)` 之后紧接着调 `confirm()`，闭包里拿到的仍是**上一帧**的空串，
   *   于是福利静默丢掉、而落库和话术都不会报错。
   */
  const confirm = async (benefitNow: string = benefit) => {
    setSubmitting(true);
    setError(null);
    try {
      const res = await assignmentsApi.create({
        // ⭐ requestId **原样回传**服务端铸造的那个 —— 幂等键。
        //   前端自己生成挡不住 SSE 重连与重试。
        requestId,
        clinicId: sheet.clinicId,
        // 初筛条件快照:批次要能解释"当时按什么圈的"
        criteria: {
          potentialTreatment: sheet.potentialTreatment,
          // ⭐ 温度必须一起存 —— 矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」是两批
          //   完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的,
          //   也没法反推"哪种格子分下去效果好"(实测漏了很久)。
          temperature: sheet.temperature,
          candidateTotal: sheet.candidateTotal,
          target: sheet.target,
          selectionNote: sheet.selectionNote,
          // ⚠️ 存的是**基数**不是 target —— 候选不够时 target 被压低,那是这一批的偶然事实。
          //   存 target 的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人。
          //   ⚠️ 同时 target 也在(上面)—— 沿用时两个键都认,改版不丢"沿用"。
          batchSize: sheet.batchSize,
          expiresInDays,
          agentOverrides: carryOverrides(sheet.agentOverrides),
          // 主管在卡片上做过的局部修正 —— 事后要能解释"这批为什么跟助手算的不一样"
          manualEdits: {
            dropped: [...dropped],
            moved: Object.keys(moveByPlan).length,
            perItemExpiry: Object.keys(dayByPlan).length,
          },
        },
        // T4:福利挂批次(话术勾子 + 归因标签);空串 = 不带福利,⛔ 别落一个空 benefit 对象
        ...(benefitNow.trim() ? { attributes: { benefit: { text: benefitNow.trim() } } } : {}),
        expiresInDays,
        // ⚠️ 落库的是**卡片当前值**,不是模型原提案。
        //   逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时限冻进每条,
        //   将来批次层面改时限(撤销重发之类)就改不动这些单子了。
        items: items.map((it) => ({
          planId: it.planId,
          assigneeUserId: it.assignee,
          // ⭐ 拖过的标 manual(主管指定)—— 不标的话事后分析会把它算成算法的选择,
          //   而 T20 要反推的正是"算法选得准不准"
          assignStrategy: it.moved ? AssignStrategy.MANUAL : it.assignStrategy,
          selectionMode: it.selectionMode,
          ...(it.days !== expiresInDays ? { expiresInDays: it.days } : {}),
        })),
      });
      const summary =
        '已确认分配:' +
        // ⚠️ 用中文项目名,⛔ 别把 `endo` / `filling` 这种码摆到主管眼前(说人话,见 assistant-prompts 第 0 条)
        (sheet.potentialTreatment
          ? `${potentialTreatmentItemName(sheet.potentialTreatment)} · `
          : '') +
        `${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` +
        (dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') +
        (res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
      setConfirmedAt(Date.now());
      /**
       * ⭐ 让主管工作台的两张表重新拉一次 —— 确认完他最想看的就是自己刚分的那一批。
       * ⚠️ 用全局信号而不是回调:确认单在**助手浮层**里,两张表在页面上,两边没有父子关系。
       * ⚠️ `getState()` 直接取,⛔ 不用 hook 订阅 —— 这里只发不收,订阅了反而会因为
       *   seq 自增把这张卡片也重渲染一遍。
       */
      useAssignmentSyncStore.getState().notifyChanged();
      // 🔴 模型那份**必须带完整 uuid**:它要撤销只能靠这里拿 id。
      //   此前这句写的是 `批次 #${id.slice(0,8)}`,两边共用 —— 于是模型手里只有 8 位短号,
      //   一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的,
      //   两条路测一条就漏了(实测 2026-08-03)。
      //   ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。
      /**
       * 🔴 **确认之后的福利提醒不再由卡片摆**（2026-08-15 产品定）。
       *
       * 这里原来会造一个 `benefit_missing` 引导节点（`stage: 'post_confirm'`），
       * 由 `settleSheet` 合进这张单的 signals —— 于是主管点完确认，卡片里
       * **长出一块黄色的引导**。产品判定：确认单里不做福利引导，确认后正常输出就够。
       * ⚠️ 提醒本身没取消，只是换了出口 —— 现在归**模型**说
       *   （提示词 ④「确认之后」那一节；`本批福利` 这个事实一直在下面那份 modelFacts 里）。
       * ⛔ 别把这段"改成拼一句话塞进对话"：那是更早被否掉的做法
       *   （前端替助手开口，署的是它的名而它根本没说过）。
       * ⚠️ 时机仍然要紧：福利只影响**此后生成**的话术，客服从这一刻开始陆续打开这批单，
       *   每过一会儿能用上它的人就少一个 —— 所以③已经在**确认之前**先问过一次。
       */
      onConfirmed(
        res.assignmentId,
        summary,
        /**
         * 喂模型那份 —— **只有事实**,⛔ 一个字的指令都不许有。
         * 🔴 批次 id 必须是**完整 uuid**:它要撤销这批只能靠这里拿 id
         *   (此前写成 8 位短号,模型一调 revoke_assignment 就报「批次不存在」,
         *    而卡片上的撤销按钮握着完整 id 一直是好的 —— 两条路测一条就漏了)。
         */
        JSON.stringify({
          已确认分配: {
            批次id: res.assignmentId,
            落了几条: res.assigned,
            几位客服: groups.length,
            时限天数: expiresInDays,
            ...(dropped.size ? { 主管移除: dropped.size } : {}),
            ...(res.skipped.length ? { 没落上: res.skipped.length } : {}),
            本批福利: benefitNow.trim() || null,
          },
        }),
      );
    } catch (e) {
      setError(describeConfirmError(e));
    } finally {
      setSubmitting(false);
    }
  };

  /**
   * 移除某个客服 = 他名下的条目**一并移出本批**。
   *
   * 🔴 **这是第七条改动路径，2026-08-15 补回声时漏掉了**（当天的契约测试抓到）。
   *   我数了拖动改派 / 点 ×（两处）/ 整批时限 / 每行时限 / 引导按钮改时限 六条，
   *   偏偏漏了这条 —— 而它一次移走的是**一整个客服名下的所有条目**，
   *   静默起来比单点一个 × 严重得多：模型下一轮还以为那几条在他手上。
   * ⚠️ 报的是**人名 + 条数**：主管点的是"移除这个人"，回声里只说条数他对不上是谁。
   */
  const removeAgent = (userId: string) => {
    const g = groups.find((x) => x.userId === userId);
    setDropped((s) => {
      const n = new Set(s);
      for (const it of items) if (it.assignee === userId) n.add(it.planId);
      return n;
    });
    echo(`确认单已更新:把 ${g?.name ?? userId.slice(0, 8)} 名下的 ${g?.list.length ?? 0} 条移出本批。`);
    setPendingRemove(null);
  };

  const removingGroup = groups.find((g) => g.userId === pendingRemove);

  /**
   * 🔴 执行助手下发的**语义编辑指令**。
   *
   * 模型只会说「把杨丽华移出这批」——**姓名由这里去匹配**(它没有 planId,那是故意的)。
   * ⚠️ 匹配不到 / 匹配到多个都要**如实回一句**,⛔ 不许静默跳过:
   *   模型看不到卡片,静默失败会让它跟主管说"已经移除了",而那个人还在。
   * ⚠️ `appliedRef` 记已执行到第几批 —— 组件每次重渲染 edits 都在,不挡就会重复执行。
   *
   * 🔴🔴 **确认之后这些 state 已经没人读了**(2026-08-06 实测的事故)。
   *   确认前:改 state → `confirm()` 把 state 打包发给服务端,一切正常。
   *   确认后:批次早就落库了,`confirm()` 再也不会跑 —— 改 state 只是改了个界面,
   *   而卡片照样亮角标、照样回一句「已更新」,主管完全看不出什么也没发生。
   *   实测就是这么丢的:确认完再说「这批带上八折」,角标亮了,话术里没有福利。
   *   ⇒ 确认后**必须分两条路**:福利走真实接口补写(见下),其余一律**如实拒绝**。
   *   ⛔ 别再往这个 effect 里加"只改 state"的分支而不看 `assignmentId`。
   */
  const appliedRef = useRef(0);
  useEffect(() => {
    if (!edits?.length) return;
    const fresh = edits.filter((e) => e.seq > appliedRef.current);
    if (!fresh.length) return;
    appliedRef.current = Math.max(...edits.map((e) => e.seq));

    const notes: string[] = [];
    // ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术),
    //   而且必须走接口真写库;人员 / 时限那些动的是已经发到客服手上的单 ——
    //   ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
    //   ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
    if (assignmentId) {
      for (const { ops } of fresh) {
        for (const op of ops) {
        if (op.action === 'set_benefit') {
            void assignmentsApi
              // ⚠️ schema 的 superRefine 保证 set_benefit 必有 text;`?? ''` 只是类型兜底
              .setBenefit(assignmentId, op.text ?? '')
              .then((r) => {
                setBenefit(r.benefitText ?? '');
                // ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存"
                //   和"几条客服已经打开过",这两个数只有服务端知道(T14)。
                echo(`确认单已更新:${r.note}`);
              })
              .catch((e) =>
                echo(
                  `福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`,
                ),
              );
            continue;
          }
          notes.push(
            `这批**已经确认分配**了,「${OP_ZH[op.action] ?? op.action}」在这张单上改不了 —— ` +
              `单子已经在客服手上 —— 让我把在手的摆出来调(换人、改时限、移出),` +
              `或者撤销整批重新分`,
          );
        }
      }
      if (notes.length) echo(`确认单没有改动:${notes.join(';')}。`);
      return;
    }
    /**
     * 🔴🔴 **同一批指令里,后一条必须看得见前一条的结果**(2026-08-06 实测)。
     *
     * `setMoveByPlan` / `setDropped` 是 React state,**本次 effect 里读不到刚写的值**。
     * 实测事故:主管一句话里给了两条指令 ——
     *   「李汝明分配给李闻,剩下的都分配给高瑞珍」
     * 「改派李汝明→李闻」先执行,但紧接着那条「待分配整组铺平」读的还是**旧的**
     * `moveByPlan`,于是李汝明仍被当成"待分配",连同其余 31 人一起铺给了高瑞珍 ——
     * **前一条指令被后一条静默覆盖**,而两条都回报了"已执行"。
     *
     * ⇒ 本次 effect 内一律读写下面这两个**草稿**,最后一次性提交。
     * ⛔ 别在循环里直接读 `moveByPlan` / `dropped`,那是事故的原样复现。
     */
    const moveDraft: Record<string, string> = { ...moveByPlan };
    const dropDraft = new Set(dropped);
    /// 还留在「待分配」里的(按草稿算)—— 每次用前重算,别缓存
    const pendingRest = () =>
      (sheet.pending ?? []).filter((p) => !dropDraft.has(p.planId) && moveDraft[p.planId] == null);

    /**
     * 按姓名 / 病历号找人。
     *
     * ⚠️ 用 items(生效条目)不是 sheet.items —— 已经删掉的人不该再被"找到"。
     * 🔴 **必须把「待分配」也算进来**(2026-08-06 实测):主管说「把常瀚越分配给刘艳阳」,
     *   而常瀚越正躺在待分配里 —— 只搜 items 会回一句「确认单里没有『常瀚越』,跳过」,
     *   主管明明看着他的名字就在卡片上。⇒ **卡片上看得见的人,指令就必须能指到。**
     * ⚠️ 待分配的条目没有 assignee:`action:'assign'` 命中它 = 主管拍板把人给谁(标「指定」),
     *   `action:'remove'` 命中它 = 移出本批,两者都走同一套 moveByPlan / dropped,天然生效。
     */
    const findPatients = (q: string) => {
      const k = q.trim();
      const hit = (i: { patientName: string | null; medicalRecordNumber: string | null }) =>
        i.patientName === k || i.medicalRecordNumber === k;
      // ⚠️ 待分配那半边按**草稿**过滤:同批次前一条指令刚处置过的人不该再被找到
      return [...items.filter((i) => !dropDraft.has(i.planId)).filter(hit), ...pendingRest().filter(hit)];
    };
    /**
     * 按姓名 / userId 找客服。
     *
     * 🔴 **必须把「本批未分到」的人也算进来**(2026-08-06 实测):主管说
     *   「把陈浡龙分配给刘艳阳」,而刘艳阳这轮一条都没分到 —— 只搜 `groups`(有条目的)
     *   会回「没找到客服『刘艳阳』」,而他的名字**就印在卡片下面那行「本批未分到」里**。
     *   ⇒ 与 findPatients 同一条规矩:**卡片上看得见的,指令就必须能指到。**
     * ⚠️ 而且这些人恰恰是**手上最空**的,是主管最可能想分给他的对象。
     * ⚠️ 返回 `list` 供 `select.group='agent'` 用;未分到的人 list 为空(他本来就没条目)。
     */
    const findAgent = (q: string) => {
      const k = q.trim();
      const inSheet = groups.find((g) => g.name === k || g.userId === k);
      if (inSheet) return inSheet;
      const idle = sheet.skippedAgents.find((a) => a.name === k || a.userId === k);
      return idle
        ? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] }
        : null;
    };

    for (const { ops } of fresh) {
      for (const op of ops) {
        /**
         * ── ① 选谁 ───────────────────────────────────────────────
         * 返回 null = 已经回过话(没找到 / 有歧义),这条指令到此为止。
         * ⚠️ 每条指令**现算**,⛔ 别提到循环外缓存:前一条刚移出/改派过的人不该再被选中。
         */
        const resolve = (): { planIds: string[]; label: string } | null => {
          if (op.select.group === 'patients') {
            const planIds: string[] = [];
            const names: string[] = [];
            for (const q of op.select.patients) {
              const hit = findPatients(q);
              /**
               * 🔴 **同名歧义 —— 与 `group:'agent'` 那边对称**（2026-08-13 实测补的）。
               *
               * 主管说「删除闫保玉」，模型按规矩默认选了 `patients`（那条规矩是对的:
               * 多数情况下他说的就是患者）。而闫保玉**既是本批客服、又是本批患者** ——
               * 卡片二话不说移除了那个同名患者并回报"已移除 1 人"，
               * 而主管盯着的那一行客服（14 条）纹丝不动。他看到的是"系统说成了，其实没成"。
               * ⛔ 这种时候不许猜:两边动的东西差 14 倍，而回报还写着成功。
               */
              const alsoAgent = findAgent(q);
              if (alsoAgent && hit.length > 0) {
                notes.push(
                  `「${q}」既是本批客服、又是本批患者,没有执行 —— ` +
                    `请说清是「患者 ${q}」还是「客服 ${q} 名下的」`,
                );
                continue;
              }
              /**
               * ⭐ **只匹配到客服 → 就按客服办**（2026-08-13 第二版）。
               *
               * 上一版这里是"拒绝执行 + 告诉他正确说法",实测被主管当成 bug:
               * 他说「移除逯梓茜」,界面回一句"请换个说法",人还在名单上 —— 「没有移除啊」。
               * ⚠️ 这里**没有歧义可言**:本批一个叫这名的患者都没有,唯一的读法就是客服。
               *   拒绝执行不是谨慎,是把一个只有一种解释的指令推回去。
               * ⛔ 但**必须说清替他理解成了什么、动了几条** —— 客服那一行是 15 条,
               *   而他嘴上说的是一个人名;不点破就是"我以为只动了 1 个"。
               * ⛔ 歧义（既是客服又是患者）那条仍然不许猜,见上面一段。
               */
              if (alsoAgent) {
                const live = alsoAgent.list.filter((it) => !dropDraft.has(it.planId));
                if (live.length === 0) {
                  notes.push(`「${q}」是本批客服,但他本批一条都没分到,没什么可动的`);
                  continue;
                }
                notes.push(`「${q}」是本批客服(没有同名患者),按客服处理`);
                planIds.push(...live.map((it) => it.planId));
                names.push(`客服 ${alsoAgent.name ?? q} 名下 ${live.length} 条`);
                continue;
              }
              if (hit.length === 0) {
                notes.push(`确认单里没有「${q}」,跳过`);
                continue;
              }
              if (hit.length > 1) {
                // ⛔ 同名时不猜:猜错等于把不该动的人动了,而主管看不出来
                notes.push(`有 ${hit.length} 位叫「${q}」,没有执行 —— 请用病历号指定`);
                continue;
              }
              planIds.push(hit[0]!.planId);
              names.push(hit[0]!.patientName ?? q);
            }
            return planIds.length ? { planIds, label: `「${names.join('、')}」` } : null;
          }
          if (op.select.group === 'agent') {
            const name = op.select.agent;
            const g = findAgent(name);
            if (!g) {
              notes.push(`没找到客服「${name}」,跳过`);
              return null;
            }
            /**
             * 🔴 同名歧义:实测「张悦」既是本批客服、又是本批患者,模型选了"移除客服",
             *   于是"把张悦移出确认单"变成了把**客服**张悦连同他名下 20 条一起移除。
             *   ⛔ 这种时候不许猜 —— 猜错动的是 20 条,而主管以为只动了 1 条。
             */
            if (findPatients(name).length > 0) {
              notes.push(
                `「${name}」既是本批客服、又是本批患者,没有执行 —— ` +
                  `请说清是「患者 ${name}」还是「客服 ${name} 名下的」`,
              );
              return null;
            }
            const live = g.list.filter((it) => !dropDraft.has(it.planId));
            if (live.length === 0) {
              // ⛔ 别报"已处理 0 条"(听起来像做了什么)
              notes.push(`「${g.name ?? name}」本批一条都没分到,没什么可动的`);
              return null;
            }
            return {
              planIds: live.map((it) => it.planId),
              label: `客服「${g.name ?? name}」名下 ${live.length} 条`,
            };
          }
          if (op.select.group === 'pending') {
            const rest = pendingRest();
            if (rest.length === 0) {
              notes.push('「待分配」已经没有待处理的人了');
              return null;
            }
            return { planIds: rest.map((p) => p.planId), label: `「待分配」的 ${rest.length} 人` };
          }
          // batch:整批 —— 只有 set_expiry / set_benefit 走得到这里(schema 已拦住其余)
          return { planIds: [], label: '整批' };
        };

        // ── ② 干什么 ───────────────────────────────────────────────
        if (op.action === 'set_benefit') {
          setBenefit(op.text ?? '');
          notes.push((op.text ?? '').trim() ? `本批福利设为「${op.text}」` : '已撤掉本批福利');
          continue;
        }
        if (op.action === 'set_expiry' && op.select.group === 'batch') {
          setExpiresInDays(op.days!);
          notes.push(`整批时限改为 ${op.days} 天`);
          continue;
        }
        const sel = resolve();
        if (!sel) continue;

        if (op.action === 'remove') {
          for (const id of sel.planIds) dropDraft.add(id);
          notes.push(
            op.select.group === 'pending'
              ? `已把${sel.label}全部移出本批(他们回池子,下次还能再分)`
              : `已从本批移除${sel.label}`,
          );
          continue;
        }

        if (op.action === 'set_expiry') {
          const days = op.days!;
          setDayByPlan((m) => {
            const n = { ...m };
            for (const id of sel.planIds) n[id] = days;
            return n;
          });
          // ⚠️ 待分配的人还没有经办人:时限先记下,等他被分给某位客服才随之落库。
          //   ⛔ 别默不作声 —— 主管会以为这条已经进批次了。
          const stillPending = pendingRest().filter((p) => sel.planIds.includes(p.planId)).length;
          notes.push(
            `${sel.label}时限改为 ${days} 天` +
              (stillPending > 0
                ? `(其中 ${stillPending} 人还在「待分配」里,分给客服后这个时限才生效)`
                : ''),
          );
          continue;
        }

        // ── ③ 给谁(action === 'assign')──────────────────────────
        /**
         * ⭐ 边界:**决定是主管做的,执行是助手做的**。取消自动改派后待分配动辄几十人,
         *   只给逐条改派等于把 55 次拖拽推回给他 —— 那不是保护,是甩锅。
         * ⚠️ 一律标「指定」(MANUAL)—— 这是人的决定,⛔ 别混进算法策略里(T20 要反推的正是算法)。
         */
        const to = op.to!;
        if (to.mode === 'owner') {
          /**
           * 🔴 **各自还给自己的专属客服** —— 与 balance **语义正好相反**。
           *
           * 由来(2026-08-06 实测):主管说「把待分配的患者各自分给到各自的专属客服」,
           * 当时只有铺平一种铺法,于是 18 个人被散给了 17 位**别人**。
           * ⭐ 这条反而最安全:收的人就是患者自己那位专属客服,⛔ 一条关系都不动。
           * ⚠️ 专属客服已离岗 / 不在本批名册的**分不下去**,如实报数,
           *   ⛔ 别顺手改派给别人(那就又犯了一遍那个错)。
           */
          const roster = new Set([
            ...sheet.byAgent.map((a) => a.userId),
            ...sheet.skippedAgents.map((a) => a.userId),
          ]);
          /// 专属关系只有「待分配」那份数据带着(pending[].ownerUserId);
          /// 已落到人头上的条目本来就在他自己的专属客服手上,不需要还
          const ownerOf = new Map(
            (sheet.pending ?? []).map((p) => [p.planId, { id: p.ownerUserId, name: p.ownerName }]),
          );
          const done = new Map<string, number>();
          let orphan = 0;
          for (const id of sel.planIds) {
            const owner = ownerOf.get(id);
            if (!owner?.id || !roster.has(owner.id)) {
              orphan++;
              continue;
            }
            moveDraft[id] = owner.id;
            const key = owner.name ?? owner.id;
            done.set(key, (done.get(key) ?? 0) + 1);
          }
          const detail = [...done].map(([n, c]) => `${n} ${c} 条`).join('、');
          notes.push(
            done.size === 0
              ? `一个也没分下去:${sel.label}查不到在本批名册里的专属客服(可能已离岗)`
              : `已把${sel.label}中的 ${sel.planIds.length - orphan} 人**各自还给自己的专属客服**(${detail})` +
                  (orphan > 0 ? `;另有 ${orphan} 人查不到在岗的专属客服,没动他们` : ''),
          );
          continue;
        }

        /**
         * 铺平 —— **谁手上少先给谁**,与服务端 placeAgents 的 `lowest()` 同一条规则。
         * ⛔ 不是真随机:主管嘴上说"随机",要的是齐平;真随机会让同一张单每次算出不同结果。
         */
        // 候选客服:主管点名的 → 按姓名解析;没点名 → 本批所有在岗(含一条都没分到的,
        // ⚠️ 他们恰恰是最空的那批,漏了就等于把人又堆给已经满的人)
        const roster = [
          ...sheet.byAgent.map((a) => ({ userId: a.userId, name: a.name, load: a.inHandBefore })),
          ...sheet.skippedAgents.map((a) => ({ userId: a.userId, name: a.name, load: a.inHand })),
        ];
        const missed: string[] = [];
        const targets = to.agents?.length
          ? to.agents
              .map((q) => {
                const hit = roster.find((r) => r.name === q.trim() || r.userId === q.trim());
                if (!hit) missed.push(q);
                return hit;
              })
              .filter((x): x is (typeof roster)[number] => !!x)
          : roster;
        if (targets.length === 0) {
          notes.push(
            missed.length
              ? `没找到客服「${missed.join('、')}」,一个人也没分`
              : '本批没有可分配的客服,一个人也没分',
          );
          continue;
        }
        // 水位 = 分配前在手 + 本轮已给。先把卡片上**当前**的分配算进去,否则会把人堆给已经满的
        const load = new Map(targets.map((t) => [t.userId, t.load]));
        // ⚠️ 已在卡片上的条目要按**草稿**算经办人:同批次前一条刚改派过的人,
        //   算在旧客服头上会让水位偏一格。
        // ⚠️ 本次要改派的这批人先排除掉 —— 他们马上要重新落位,算进去等于算了两遍
        const moving = new Set(sel.planIds);
        for (const it of items) {
          if (dropDraft.has(it.planId) || moving.has(it.planId)) continue;
          const who = moveDraft[it.planId] ?? it.assignee;
          if (load.has(who)) load.set(who, (load.get(who) ?? 0) + 1);
        }
        const next: Record<string, string> = {};
        for (const id of sel.planIds) {
          // 谁手上最少给谁;同数按 userId 升序打破平局(确定性 —— 同样输入同样结果)
          let who = targets[0]!.userId;
          for (const t of targets) {
            const l = load.get(t.userId) ?? 0;
            const best = load.get(who) ?? 0;
            if (l < best || (l === best && t.userId < who)) who = t.userId;
          }
          next[id] = who;
          moveDraft[id] = who;
          load.set(who, (load.get(who) ?? 0) + 1);
        }
        const nameOf = new Map(targets.map((t) => [t.userId, t.name ?? t.userId]));
        const spread = [...new Set(Object.values(next))]
          .map((u) => `${nameOf.get(u)} ${Object.values(next).filter((x) => x === u).length} 条`)
          .join('、');
        notes.push(
          `已把${sel.label}按**谁手上少先给谁**铺给 ${targets.length} 位客服(${spread})` +
            (missed.length ? `;没找到「${missed.join('、')}」` : ''),
        );
      }
    }
    // ⭐ **一次性提交草稿** —— 循环里只改草稿,这里才落到 state。
    //   ⛔ 别改回"每条指令各自 setState":那会让同批次的后一条读不到前一条(见上面的事故)。
    // ⚠️ 用函数式合并而不是整个替换:草稿只增不删,合并等价且不会覆盖掉
    //   effect 排期期间主管手动拖出来的改动。
    setMoveByPlan((m) => ({ ...m, ...moveDraft }));
    setDropped((sset) => new Set([...sset, ...dropDraft]));
    if (notes.length) echo(`确认单已更新:${notes.join(';')}。`);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [edits]);

  /// 找到离卡片最近的可滚动祖先(助手窗的消息区),拖拽自动滚动用
  /**
   * 引导节点的选项路由。
   * ⚠️ 只截下卡片**本来就有实现**的两个，其余一律上抛 —— 见 `onIntent` 的注释。
   */
  const handleIntent = async (intent: string, args?: Record<string, unknown>) => {
    if (intent === 'expiry.set' && typeof args?.days === 'number') {
      setExpiresInDays(args.days);
      echo(`确认单已更新:整批时限改成 ${args.days} 天。`);
      return;
    }
    // ⚠️ `pending.refill` 曾经在这里实现过 —— 待分配的按钮搬到文案下面之后
    //   （见 signal-prompts / SIGNAL_IN_TEXT）,它改由 use-assistant-chat 处理。
    //   ⛔ 别在这里补回来:同一个 intent 两份实现必然漂。
    onIntent?.(intent, args);
  };

  /**
   * 主语还在的引导节点。
   * ⚠️ 判据只用**卡片本地就能看见**的事实，⛔ 不重算服务端的判定：
   *   · `pending`  —— 待分配被处置光了，这条就没有主语了
   *   · `batch_size_basis` —— 主管改过时限，"本批 N 人 = 在岗 × 每天几通 × D 天"里的 D 就变了
   *   其余（候选不足 / 工作量 / 画像亮点）本地改单不影响，或需重跑才知道 → 原样留着。
   */
  const liveSignals = useMemo(
    () =>
      (sheet.signals ?? []).filter((s) => {
        if (s.key === 'pending') return pendingList.length > 0;
        if (s.key === 'batch_size_basis') return expiresInDays === sheet.expiresInDays;
        // 🔴 这里曾有一条 `benefit_missing`（确认后卡片自己摆的福利提醒）——
        //   随那条节点一起删于 2026-08-15。⛔ 别加回来：确认单里不做福利引导。
        return true;
      }),
    [sheet.signals, sheet.expiresInDays, pendingList.length, expiresInDays],
  );

  /**
   * 卡片上要显示的那几条 = 还没被模型插进正文的。
   * ⚠️ 判据就是 `placedKeys`，⛔ 别在这里另立一份"哪些该搬走"的名单 ——
   *   位置归模型决定（show_guidance），卡片只负责收尾。
   */
  const cardSignals = useMemo(
    () => liveSignals.filter((s) => !(placedKeys ?? []).includes(s.key)),
    [liveSignals, placedKeys],
  );
  /**
   * 🔴 **这一轮还在写的时候，卡内兜底一个都不渲染**（2026-08-13 产品实测）。
   *
   * 卡片是在 `propose_assignment` 那一刻就推来的，而模型要**再跑好几步**才把
   * 节点一个个 `show_guidance` 到正文里。这中间 `placedKeys` 还是空的 ——
   * 于是四五条节点连同按钮先在卡片里整块闪出来，等模型放完又一条条消失。
   * 主管看到的是一坨乱跳的东西。
   *
   * ⚠️ ⛔ 不能因此把卡内兜底删掉：模型漏调 `show_guidance` 时，卡片是这些节点
   *   **唯一**还看得见的地方（藏了就是"有人会被漏掉"也一起藏了）。
   * ⇒ 只推迟到这一轮说完：说完还没被放进正文的，才在卡片上显示。
   */
  const settledSignals = streaming ? [] : cardSignals;

  /**
   * 每位客服「本批之前在手 / 本批又拿了几条」—— 待分配那一组要用它解释"为什么排满了"。
   * ⚠️ 从 `groups` 现算,⛔ 不用 `p.ownerInHand`:后者是服务端快照(本批之前的量),
   *   主管在卡片上拖走几个人之后它不会变,而"排满"这件事恰恰会变。
   */
  const ownerLoad = useMemo(
    () => new Map(groups.map((g) => [g.userId, { before: g.inHandBefore, batch: g.list.length }])),
    [groups],
  );

  /**
   * 🔴 **把"还活着的节点"报给上层** —— 搬到正文里的那份引导节点要用同一个判据。
   *
   * 由来（2026-08-13 实测）：点「各自归专属客服」后待分配清零、卡片头也更新了，
   * 而正文里那四个按钮**还在**，再点一次是空操作 —— 正文那份读的是服务端快照
   * （`sheet.signals`），本地改单它不会知道。
   * ⛔ 别在上层另写一份"哪些还活着"的判定：判据只有卡片看得见（`pendingList` 是
   *   服务端 pending 减去本地 edits 的结果），复制过去必然漂。
   * ⚠️ **fail-open**：上层拿不到这份上报时按"都还在"渲染 —— 宁可多显示一次，
   *   ⛔ 也不能把「还有人没发出去」静默藏掉。
   */
  const liveKeys = liveSignals.map((s) => s.key).join(',');
  useEffect(() => {
    onLiveSignalsChange?.(requestId, liveKeys ? liveKeys.split(',') : []);
  }, [requestId, liveKeys, onLiveSignalsChange]);

  const rootRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let el = rootRef.current?.parentElement ?? null;
    while (el) {
      const oy = getComputedStyle(el).overflowY;
      if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) break;
      el = el.parentElement;
    }
    scrollerRef.current = el;
  }, []);

  return (
    <Card
      ref={rootRef}
      onDragOver={onDragOverAnywhere}
      onDragEnd={stopAutoScroll}
      onDrop={stopAutoScroll}
      className={
        superseded
          ? // ⚠️ 作废态压低存在感:主色边框换成中性、内容压到 60% 不透明度。
            //   🔴 2026-08-13 起**整条消息通常已经不渲染了**(见 chat-blocks.isDiscardedDraft) ——
            //     这里只剩一种情况会走到:同一轮出了两版,旧的与新的在**同一条消息**里,
            //     那时这张仍要显示(带下面的作废横幅),否则主管看不出上面那张为什么不能点。
            'overflow-hidden border-slate-200 text-[12px] opacity-60 shadow-none'
          : 'overflow-hidden border-brand-100 text-[12px] shadow-sm'
      }
    >
      {/*
        ⭐ 作废横幅 —— 钉在**卡片顶部**。
        🔴 光靠底部那句不够:确认单可以很长,主管的视线停在卡片上半部分时
          根本看不到底下写着"已作废",而顶部这条第一眼就在。
      */}
      {superseded && (
        <div className="border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-[11px] font-medium text-amber-800">
          这一版已作废，不能确认 —— 当前有效的是下面最新的那一张
        </div>
      )}
      {/*
        ① 汇总
        🔴 **必须 `flex-wrap`**(2026-08-11 产品指出):窗口窄的时候这一行装不下
          「共 N 人 · 拟分 X · 待分配 Y」+ 角标 + 时限下拉,不换行就只能挤 ——
          实测「已移除 1」被压成**竖排单字**,时限下拉顶出卡片边界。
        ⚠️ 换行之后 `ml-auto` 仍然有效(它按**行**推到右端),时限自己落到第二行右侧。
        ⚠️ `gap-y-1` 不能省:换行后两行会贴在一起。
      */}
      <div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b bg-brand-50/50 px-3 py-2">
        <Users className="h-3.5 w-3.5 flex-none text-brand-600" />
        {/*
          ⚠️ 有待分配时必须**先给总数**,再拆两半。
          光写「已排好 21 人 · 待分配 39 人」读不出 21 是不是含 39 —— 主管第一反应会是
          "那到底分了几个"。先摆一个 60,后面两个数就是它的拆分,不用猜(T14:数字要能对上)。
          ⛔ 别把待分配算进「已排好」:那 39 条**不会落库**,算进去就是虚报。
        🔴 措辞是「**已排好**」,⛔ 不是「已分配」(2026-08-13 定):它和「待分配」成对,
          说的是**这张单内部**的状态(已经落到人头上 vs 还没排上)。
          「已分配」会被读成"已经发出去了",而点确认之前一条都没落库 —— 那正是硬约束第 1 条。
        */}
        <span className="font-semibold text-slate-800">
          {pendingList.length > 0 ? (
            // ⚠️ 每段各自 nowrap:窄面板里必然换行,不锁的话会断成「待分配 3 / 9」,
            //   数字被劈成两行 —— 断要断在「·」上,不能断在数字中间。
            <>
              <span className="whitespace-nowrap">共 {items.length + pendingList.length} 人</span>
              <span className="whitespace-nowrap">
                {' '}· 已排好 {items.length} 人
              </span>
              <span className="whitespace-nowrap text-amber-800">
                {' '}· 待分配 {pendingList.length} 人
              </span>
            </>
          ) : (
            <>
              已排好 {items.length} 人 · {groups.length} 位客服
            </>
          )}
        </span>
        {/* ⚠️ 角标一律 `flex-none whitespace-nowrap`:不锁的话 flex 会把它们压到零宽,
            中文再按**单字**折行(那正是「已移除 1」变成竖排的原因)。 */}
        {dropped.size > 0 && (
          <Badge variant="secondary" className="flex-none whitespace-nowrap text-[10px] font-normal">
            已移除 {dropped.size}
          </Badge>
        )}
        {sheet.unplaced > 0 && (
          <Badge
            variant="secondary"
            className="flex-none whitespace-nowrap bg-amber-100 text-[10px] font-normal text-amber-700"
          >
            {sheet.unplaced} 人分不下去
          </Badge>
        )}
        {/* 福利:有才显示。⚠️ 必须看得见 —— 它会进这批人的话术,主管点确认前有权知道说了什么 */}
        {benefit.trim() && (
          <Badge
            variant="secondary"
            className="max-w-[40%] truncate bg-emerald-50 text-[10px] font-normal text-emerald-700"
            title={benefit}
          >
            福利 · {benefit}
          </Badge>
        )}
        {/* ⭐ 批次时限放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
            放在底部微调区会让人以为它跟下面的逐条时限是一回事。
            ⚠️ 文案不能是光一个「时限」—— 主管要知道这个数**管什么**,不然它对他没有意义。
            🔴 2026-08-19 由「N 天**后自动退回**」改成「N 天**内打完**」:到期不再回池了
              (沿革见 plan.module 的墓碑),原文是在许一个不会兑现的承诺。
            ⚠️ 到期之后会怎样(记为超期、单子仍在他手上)写在下面那行操作说明里,
              ⛔ 不塞进这个角落 —— 这里只有一行的宽度,塞进去会把选择器挤到换行。 */}
        <div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500">
          <DaySelect
            value={expiresInDays}
            disabled={readOnly}
            onChange={(d) => {
              setExpiresInDays(d);
              echo(`确认单已更新:整批时限改成 ${d} 天。`);
            }}
          />
          <span>内打完</span>
        </div>
      </div>

      {/*
        ①.1 **这张单能怎么改** —— 卡片自己的操作说明,紧贴汇总行。
        🔴 2026-08-13 换掉了原来的「运营结论」(每人拿到几条、最满的要几天打完):
          那些数模型在正文里已经讲过一遍,而且现在有 `batch_size_basis` / `daily_overload`
          两个引导节点专门管它们 —— 卡片再写就是第三遍。
        ⭐ 这个位置留给**只有卡片能做的事**:拖、点、逐条改。模型说不了这些,
          它们也没法做成引导节点(那是针对具体一条的决策,这里是通用操作)。
        ⭐ 末尾挂**兜底**:拖点改覆盖不到的(「谁分给谁」这类)交给助手 ——
          点一下把焦点落到输入框,⛔ 不预填任何话(这里没有"是哪一条"要交代,
          预填就是界面替他开口)。
        ⚠️ 收起态也要看得见 —— 它和汇总行是主管唯一能看到的两行(见下面的折叠)。
        ⚠️ 只在**还能改**的时候显示:已确认 / 已撤销时它是误导。
      */}
      {!readOnly && (
        <div className="bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
          展开客服看名单 · <span className="text-slate-700">拖动患者</span>改派给别人 ·
          点 <span className="text-slate-700">×</span> 移出本批 · 每行可单独改时限，
          右上角改整批时限。
          {onCompose && (
            <>
              {' '}想自定义谁分给谁等更多操作，直接{' '}
              <button
                type="button"
                onClick={() => onCompose('')}
                className="rounded border border-dashed border-slate-300 px-1.5 py-0.5 text-[11px] leading-4 text-slate-500 transition-colors hover:border-brand-400 hover:text-brand-700"
              >
                我自己说
              </button>
            </>
          )}
        </div>
      )}

      {/*
        ①.6 **引导节点** —— 紧贴汇总行，⛔ 不能放卡片底部。
        主管的视线从汇总行往下走，"还有什么要我定的"必须在这一跳里被看到；
        埋到底部就等于没有（那正是「待分配」当初被漏掉的原因）。
        ⚠️ 判定在服务端（signals 随提案下发），这里只呈现 + 把 intent 抛给 handleIntent。

        🔴 **本地改单之后要撤掉已经不成立的节点**（2026-08-12 浏览器实测抓到）：
          点了「各自归专属客服」，72 人的待分配已经清零、汇总行也更新了，
          而节点区还挂着「72 人的专属客服这轮已排满」+ 四个按钮 —— 再点一次是空操作，
          主管只会觉得"我明明处理过了"。
        ⚠️ 这里**只撤掉主语已经消失的那些**，⛔ 不在前端重算判定 ——
          判定逻辑复制到两边必然漂（T6a 同源）。要真正重算得让服务端重出一版。
      */}
      <ConfirmSheetSignals
        signals={settledSignals}
        readOnly={readOnly}
        busyIntent={busyIntent}
        onIntent={handleIntent}
        onCompose={onCompose}
      />

      {/*
        ①.7 **「重新排一版」做了什么** —— 常显,⛔ 不进收起体。
        🔴 2026-08-13:一度被我关进收起体里 —— 主管**刚点完按钮**却看不到结果,
          得先展开名单才知道换了什么。按"收起的只有名单"这条规则,它本来就不该在里面。
        🔴 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」,
          而**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")—— 重排走 HTTP
          只换卡片,助手不会重新说一遍。主管看到的是两份打架的口径,合理的第一反应
          就是「19 个人怎么顶掉了 34 个」。⇒ 换了什么必须由**卡片自己**交代。
        ⚠️ 尤其是**代价**:为凑满 N,专属大户会超出建议水位多接自己的人(69 条 vs 别人 58 条)。
          不说这一句,那个 69 就成了无法解释的数。
      */}
      {sheet.refillNote && (
        <div className="whitespace-pre-line border-b border-sky-100 bg-sky-50/60 px-3 py-2 text-[11.5px] leading-relaxed text-sky-900">
          <Emphasized text={sheet.refillNote} />
        </div>
      )}

      {/*
        ⭐ 以下是**收起体**:名单类的东西。上面那几块（汇总 / 操作说明 / 引导节点）
          和下面的确认按钮都常显，⛔ 别把它们挪进来。
      */}
      {bodyOpen && (
        <>

      {/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
      <CardContent className="p-0">
        <div className="divide-y">
          {groups.map((g) => {
            const isOpen = open.has(g.userId);
            return (
              <div
                key={g.userId}
                // ⭐ 整个客服块都是投放区(不只是标题行):展开后列表很长,
                //   拖到中间松手是最自然的动作,只认标题行会让人反复试
                onDragOver={(e) => {
                  if (readOnly) return;
                  e.preventDefault();
                  setDragOver(g.userId);
                }}
                onDragLeave={() => setDragOver((u) => (u === g.userId ? null : u))}
                onDrop={(e) => {
                  e.preventDefault();
                  setDragOver(null);
                  const planId = e.dataTransfer.getData('text/plan-id');
                  if (planId) {
                    setMoveByPlan((m) => ({ ...m, [planId]: g.userId }));
                    echo(
                      `确认单已更新:「${zhPatient(planId)}」改派给 ${g.name ?? g.userId.slice(0, 8)}。`,
                    );
                  }
                }}
                className={cn(dragOver === g.userId && 'bg-brand-50/60 ring-1 ring-inset ring-brand-300')}
              >
                <div className="flex w-full items-center gap-2 px-3 py-2 hover:bg-slate-50">
                  <button
                    type="button"
                    onClick={() =>
                      setOpen((s) => {
                        const n = new Set(s);
                        if (n.has(g.userId)) n.delete(g.userId);
                        else n.add(g.userId);
                        return n;
                      })
                    }
                    className="flex-none"
                    aria-label={isOpen ? '收起' : '展开'}
                  >
                    <ChevronRight
                      className={cn(
                        'h-3 w-3 text-slate-400 transition-transform',
                        isOpen && 'rotate-90',
                      )}
                    />
                  </button>
                  {/*
                    ⚠️ 客服姓名要**可点击复制**,所以它是一个 <button> ——
                    ⛔ 不能再套在展开那个 <button> 里(button 嵌 button 是非法 HTML,
                      浏览器会把内层拎出来,点击行为随之错乱)。
                    ⇒ 展开的点击区拆成两块:左边的箭头 + 右边的条数,姓名夹在中间只管复制。
                    姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,卡片会撑爆。
                  */}
                  <span className="min-w-0 truncate font-medium text-slate-700">
                    <CopyableName name={g.name ?? `#${g.userId.slice(0, 8)}`} />
                  </span>
                  <button
                    type="button"
                    onClick={() =>
                      setOpen((sset) => {
                        const n = new Set(sset);
                        if (n.has(g.userId)) n.delete(g.userId);
                        else n.add(g.userId);
                        return n;
                      })
                    }
                    className="flex min-w-0 flex-1 items-center text-left"
                  >
                    <span className="flex-none tabular-nums text-slate-500">
                      {g.list.length} 条
                      <span className="ml-1 text-[10.5px] text-slate-400">
                        (在手 {g.inHandBefore} → {g.inHandBefore + g.list.length})
                      </span>
                    </span>
                  </button>
                  {/**
                   * ⭐ **这位客服要打几天** —— 主管看「每个人的工作量」就看这一列。
                   *
                   * ⚠️ 算的是**分完之后手上的总量**（在手 + 本批），⛔ 不是本批那几条：
                   *   他手上原本压着的那些也要打，只算增量会让这个数系统性偏小。
                   * ⚠️ 换算尺读 `sheet.dailyCalls`（这一版真用的那个数），⛔ 不引常量 ——
                   *   主管说过「按 20 通算」之后，常量算出来的天数和他刚定的口径对不上。
                   * ⚠️ 与 `daily_overload` 引导节点**同一个式子** `ceil(总量 / 每天几通)`：
                   *   那条只报最忙的一位，这一列把每个人都摆出来，⛔ 两处别算出不同的数。
                   *   （这里必须在**卡片里现算**：主管拖一个人、点一个 ×，这个数就变了，
                   *     而服务端那份是出方案那一刻的快照。）
                   * ⛔ 措辞不带「打不完 / 超了 / 过载」——它几乎每批都会有人超，
                   *   说成故障主管就会开始怀疑系统而不是做决定（同引导节点那条纪律）。
                   *   超出时限只用**颜色**提示，把判断留给他。
                   */}
                  {(() => {
                    const after = g.inHandBefore + g.list.length;
                    const need = Math.ceil(after / sheet.dailyCalls);
                    return (
                      <span
                        className={cn(
                          'flex-none tabular-nums text-[10.5px]',
                          need > expiresInDays ? 'text-amber-600' : 'text-slate-400',
                        )}
                        title={
                          `按每人每天 ${sheet.dailyCalls} 通算：分完手上共 ${after} 条 ≈ ${need} 天；` +
                          `本批时限 ${expiresInDays} 天`
                        }
                      >
                        约 {need} 天
                      </span>
                    );
                  })()}
                  {!readOnly && (
                    <Button
                      variant="ghost"
                      size="icon"
                      title="把这位客服移出本批(他名下的条目一并移除)"
                      onClick={() => setPendingRemove(g.userId)}
                      className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600"
                    >
                      <X className="h-3.5 w-3.5" />
                    </Button>
                  )}
                </div>

                {isOpen && (
                  <ul className="space-y-px bg-slate-50/60 px-2 pb-2">
                    {g.list.map((i) => (
                      <li
                        key={i.planId}
                        draggable={!readOnly}
                        onDragStart={(e) => {
                          e.dataTransfer.setData('text/plan-id', i.planId);
                          e.dataTransfer.effectAllowed = 'move';
                        }}
                        className={cn(
                          'flex items-center gap-1.5 rounded px-1.5 py-1 hover:bg-white',
                          !readOnly && 'cursor-grab active:cursor-grabbing',
                        )}
                      >
                        {!readOnly && (
                          <GripVertical className="h-3 w-3 flex-none text-slate-300" />
                        )}
                        {/* ⭐ 姓名优先;宿主没给姓名才退回 id 前缀 */}
                        <span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
                          <CopyableName name={i.patientName ?? `#${i.patientId.slice(0, 8)}`} />
                          {i.medicalRecordNumber && (
                            <span className="ml-1 font-mono text-[10px] text-slate-400">
                              {i.medicalRecordNumber}
                            </span>
                          )}
                          {/* 性别·年龄 —— 与患者详情卡片同一种写法(formatGender 归一宿主的各种取值) */}
                          {(i.gender || i.age != null) && (
                            <span className="ml-1 text-[10px] tabular-nums text-slate-400">
                              {formatGender(i.gender)}·{i.age ?? '?'} 岁
                            </span>
                          )}
                        </span>
                        {/*
                          医生 —— 主管审这批人时要看"这人是谁跟的"。
                          ⚠️ 两列都读 patient_profiles 现成的字段(与列表页同源),⛔ 别在前端另算。
                          ⭐ 文案统一成「主治 X」打头:两者相同时只此一句,不同才补「· 最近 Y」。
                            ⛔ 不用「近/主」缩写(要猜),⛔ 不缀"医生"(整列都是医生,是噪声)。
                        */}
                        {i.attendingDoctor || i.lastVisitDoctor ? (
                          <span className="flex-none text-[10px] text-slate-400">
                            主治 {i.attendingDoctor ?? i.lastVisitDoctor}
                            {i.attendingDoctor &&
                              i.lastVisitDoctor &&
                              i.lastVisitDoctor !== i.attendingDoctor && (
                                <> · 最近 {i.lastVisitDoctor}</>
                              )}
                          </span>
                        ) : null}
                        {/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂。
                            没标的就是"不是他的老客户",反而更好读。 */}
                        {i.assignStrategy === AssignStrategy.DEDICATED && !i.moved && (
                          <Badge
                            variant="secondary"
                            className="flex-none bg-brand-50 px-1 text-[10px] font-normal text-brand-700"
                          >
                            专属
                          </Badge>
                        )}
                        {i.moved && (
                          <Badge
                            variant="secondary"
                            className="flex-none bg-violet-50 px-1 text-[10px] font-normal text-violet-700"
                          >
                            指定
                          </Badge>
                        )}
                        {i.selectionMode === 'explore' && (
                          <Badge
                            variant="secondary"
                            className="flex-none bg-violet-100 px-1 text-[10px] font-normal text-violet-600"
                          >
                            探索
                          </Badge>
                        )}
                        {/* ⭐ 时限**逐条**:落库时写进这条 plan 的 assignment_expires_at */}
                        <DaySelect
                          compact
                          value={i.days}
                          disabled={readOnly}
                          onChange={(d) => {
                            setDayByPlan((m) => ({ ...m, [i.planId]: d }));
                            echo(`确认单已更新:「${zhPatient(i.planId)}」这条时限单独改成 ${d} 天。`);
                          }}
                        />
                        {!readOnly && (
                          <Button
                            variant="ghost"
                            size="icon"
                            title="本批不分这个人"
                            onClick={() => {
                              setDropped((s) => new Set(s).add(i.planId));
                              echo(`确认单已更新:「${zhPatient(i.planId)}」移出本批。`);
                            }}
                            className="h-5 w-5 flex-none text-slate-300 hover:text-rose-600"
                          >
                            <X className="h-3 w-3" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            );
          })}
        </div>
      </CardContent>

      {/*
        🔴 **待分配** —— 有专属客服、但那位客服这轮已排满的患者(2026-08-06 产品定)。
        
        原来助手会把这些人自动改派给手上最空的客服(铺平)。产品判定:
        **把患者从他的专属客服手里挪走是关系层面的决定,助手没资格替主管做。**
        ⇒ 助手不再决定,而是**把问题摊开**,单列一组交给主管。
        
        ⚠️ 视觉上必须**跳出来**(整块 amber 底 + 独立标题):它混在客服组里就等于没提醒 ——
          主管一路往下点确认,这几个人就悄悄没了。
        ⚠️ 操作与客服组**完全一致**:拖到任意客服 = 分给他(标「指定」);× = 移出本批。
        ⛔ 不处理就是不分,这几条**不会落库**(见 confirm 的 items) —— 文案必须说清这一点。
      */}
      {pendingList.length > 0 && (
        // ⚠️ 2026-08-15 产品定：**细线就够**（1px）。
        //   「待分配必须跳出来」这条**没有变** —— 跳出来靠的是整块 amber 底 + 独立标题，
        //   ⛔ 不是靠边框粗细。上面那段约束里的「粗边」二字随之作废，别再照它加回 2px。
        <div className="border-t border-amber-300 bg-amber-50/40">
          <div className="px-3 py-2">
            {/*
              ⚠️ 可收拢,但**默认展开** —— 这一组的存在本身就是要引起注意,
                默认折叠等于把它藏起来,那就回到"悄悄没了"的老问题。
              ⭐ 收拢同时是**性能**手段:待分配一次能有几十上百人,而这块原来**无条件全量渲染**
                (客服组是折叠的、展开才渲染明细,这里一直没有对等机制)。
            */}
            <button
              type="button"
              onClick={() => setPendingOpen((v) => !v)}
              className="flex w-full items-center gap-1.5 text-left text-[11.5px] font-medium text-amber-900"
            >
              <ChevronRight
                className={cn('h-3 w-3 flex-none transition-transform', pendingOpen && 'rotate-90')}
              />
              <Users className="h-3.5 w-3.5 flex-none" />
              待分配 {pendingList.length} 人 · 他们的专属客服排满了
            </button>
            {/* ⚠️ 这段说明**不跟着收拢** —— 收起来之后仍要看得见"不处理就是不分" */}
            {/*
              🔴 **只留一个按钮**(2026-08-06 走查):这块原来还铺了两行说明 + 一行分布,
                而**助手在上面那段话里已经说过一模一样的内容** —— 卡片是用来操作的,
                不是第二遍复述。⛔ 别再往这里加解释文字。
              ⚠️ 「重新排一版」= 服务端重出一版:遇到专属排满的**跳过**,从池子里往后取,
                尽量凑满 N。⛔ 它**不动任何人的专属关系** —— 这是底线,不是可选项。
              ⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。
            */}
            {/* ⛔ 这里原来有一个"重排凑满"按钮 —— 2026-08-13 去掉:
                待分配已经是引导节点,同一个动作在节点里叫「换无专属客服的患者补上」
                (intent `pending.refill`,由 use-assistant-chat 执行)。
                同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。 */}
          </div>
          {pendingOpen && (
          /*
            ⚠️ **封高度 + 自己滚**:待分配一次能有几十上百人(实测 30+),
              不封的话这一组能把整张确认单撑到几屏高,底下的「确认」按钮被顶得找不着 ——
              而主管八成是先看两眼就去点确认的。
            ⚠️ `overscroll-contain`:滚到底不要把助手窗的消息区**一起带着滚**
              (滚动链穿透,手上正拖着人的时候尤其乱)。
            ⛔ 别改成固定高度:人少的时候会留一块空白,那反而像"还有内容没加载出来"。
            ⚠️ 拖拽自动滚动**不受影响** —— `scrollerRef` 是从卡片的 parentElement 往上找的,
              找不到这个 ul(见上面那段 effect)。
          */
          <ul className="max-h-60 space-y-px overflow-y-auto overscroll-contain px-2 pb-2">
            {pendingList.map((p) => (
              <li
                key={p.planId}
                draggable={!readOnly}
                onDragStart={(e) => {
                  e.dataTransfer.setData('text/plan-id', p.planId);
                  e.dataTransfer.effectAllowed = 'move';
                }}
                className={cn(
                  'flex items-center gap-1.5 rounded px-1.5 py-1 hover:bg-white',
                  !readOnly && 'cursor-grab active:cursor-grabbing',
                )}
              >
                {!readOnly && <GripVertical className="h-3 w-3 flex-none text-amber-400" />}
                <span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
                  <CopyableName name={p.patientName ?? `#${p.patientId.slice(0, 8)}`} />
                  {p.medicalRecordNumber && (
                    <span className="ml-1 font-mono text-[10px] text-slate-400">
                      {p.medicalRecordNumber}
                    </span>
                  )}
                  {(p.gender || p.age != null) && (
                    <span className="ml-1 text-[10px] tabular-nums text-slate-400">
                      {formatGender(p.gender)}·{p.age ?? '?'} 岁
                    </span>
                  )}
                </span>
                {/* ⚠️ 医生列**必须和已分配的行一致**(2026-08-06 走查):主管审的是同一批人,
                    两组显示的字段不一样会让他以为待分配是"另一种东西"。写法与上面那段同源。 */}
                {p.attendingDoctor || p.lastVisitDoctor ? (
                  <span className="flex-none text-[10px] text-slate-400">
                    主治 {p.attendingDoctor ?? p.lastVisitDoctor}
                    {p.attendingDoctor &&
                      p.lastVisitDoctor &&
                      p.lastVisitDoctor !== p.attendingDoctor && <> · 最近 {p.lastVisitDoctor}</>}
                  </span>
                ) : null}
                {/* ⭐ 「为什么卡住」的证据:是谁的专属 + 他**这批排到哪儿了**。
                    🔴 2026-08-13 改:原来只写 `(在手 N)`,而那是**本批之前**的量 ——
                      于是一边写着「他的专属客服排满了」、一边写着「在手 0」,自相矛盾
                      (排满恰恰是因为本批已经给了他 15 条,而不是他原本就忙)。
                    ⇒ 写成 `在手 0 → 15`,和上面客服分组那一行同一个格式。
                    ⚠️ 用 `ownerLoad`(本地实时算的)而不是 `p.ownerInHand`:
                      主管拖走几个人之后,这个数要跟着变。
                    ⛔ 别只写「专属已满」—— 主管要判断的正是"这个人该不该从他手里挪走"。 */}
                <span className="flex-none text-[10px] text-amber-700">
                  专属 {p.ownerName ?? p.ownerUserId.slice(0, 6)}
                  {(() => {
                    const l = ownerLoad.get(p.ownerUserId);
                    const before = l?.before ?? p.ownerInHand;
                    // 本批一条都没给他(比如被精调成 0 名额)→ 没有"→",只报在手
                    return l && l.batch > 0
                      ? `(在手 ${before} → ${before + l.batch})`
                      : `(在手 ${before})`;
                  })()}
                </span>
                {!readOnly && (
                  <Button
                    variant="ghost"
                    size="icon"
                    title="移出本批"
                    onClick={() => {
                      setDropped((sset) => new Set(sset).add(p.planId));
                      echo(`确认单已更新:「${zhPatient(p.planId)}」移出本批。`);
                    }}
                    className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600"
                  >
                    <X className="h-3.5 w-3.5" />
                  </Button>
                )}
              </li>
            ))}
          </ul>
          )}
        </div>
      )}

      {/* 本批没分到的人 —— 仍然列出来:主管要看见"他不是被漏了"。
          ⚠️ 措辞不能写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
          (水位法没轮到)或被精调成 0 名额。 */}
      {/* ⚠️ 拖过去之后他就有条目了 —— 从这行剔掉,否则同一个人既在上面成组、又在"未分到"里 */}
      {idleAgents.length > 0 && (
        <div className="border-t px-3 py-1.5 text-[10.5px] text-slate-400">
          本批未分到：{idleAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
        </div>
      )}

      {/* ⛔ 这里原来有一块「人数 本批 340 人 / 要改说『这批 680 人』/ 时限为首次默认…」——
          撤掉(2026-08-03 走查)。人数是**只读**的,而只读信息不该占一块跟可操作区一样重的位置;
          它想说的两件事都已经有更好的落点:
            · 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着;
            · 怎么改 → 助手在消息里说,而且现在可以直接让助手改(edit_assignment_sheet)。 */}

        </>
      )}

      {/* ⛔ 这里原来常驻着一份**通用例句清单**(教主管能跟助手说哪些话)——
          2026-08-13 去掉:它想解决的「主管不知道能让助手干什么」现在由**引导节点**回答了,
          而且节点是**针对这一批的具体选项**(带人数、可点),比一份通用例句清单强得多。
          ⛔ 别加回来:两处都在教"能做什么",而通用清单永远比不过当下那几个按钮。 */}

      {/*
        ③.9 **展开 / 收起名单** —— 整宽一条，紧贴确认按钮上方。
        🔴 2026-08-13 从汇总行左上角那个小箭头搬到这儿:那个箭头**太不显眼**,
          主管根本注意不到还能展开(产品实测反馈)。
        ⭐ 放在这儿的另一个理由:它正好在**名单会长出来的地方**,
          点它 → 内容就在原地展开,视线不用跳。
        ⚠️ 文案带上数量 —— 「展开名单」是动作,「33 位客服 · 待分配 436 人」才是
          他决定要不要展开的依据。
      */}
      <button
        type="button"
        onClick={() => setBodyOpen((v) => !v)}
        aria-expanded={bodyOpen}
        className="flex w-full items-center justify-center gap-1 border-t bg-slate-50/70 py-1.5 text-[11.5px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
      >
        <ChevronDown
          className={cn('h-3.5 w-3.5 transition-transform', bodyOpen && 'rotate-180')}
        />
        {bodyOpen
          ? '收起名单'
          : `展开名单（${groups.length} 位客服${
              pendingList.length > 0 ? ` · 待分配 ${pendingList.length} 人` : ''
            }）`}
      </button>

      {/* ④ 确认 */}
      <div className="border-t px-3 py-2">
        {error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
        {superseded ? (
          /**
           * ⭐ 已被新版顶掉。**说清三件事**:作废了、为什么、去哪找当前那张。
           * ⛔ 不要只写"已失效" —— 主管会以为是系统出错,而不是他自己刚才要求重排的结果。
           */
          <span className="text-[11.5px] font-medium text-amber-700">
            这一版已作废 —— 你让我重排了一版，请看下面最新的那张
          </span>
        ) : cancelled && !revoked ? (
          // 助手撤的:本地没有三个数,只说状态 —— 具体数字助手在消息里已经原话转述过
          <span className="text-[11.5px] font-medium text-slate-500">这批已撤销</span>
        ) : done ? (
          // ⚠️ 同汇总行:撤销结果那三个数在窄窗里放不下,不 wrap 就会挤掉
          <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
            <span className="flex items-center gap-1.5 text-[11.5px] font-medium text-emerald-700">
              <Check className="h-3.5 w-3.5" />
              已分配
              {/* ⛔ 不显示批次号:`#9a9fc222` 是 uuid 前 8 位,对主管没有任何意义
                  (他不会拿它去查什么),只是占位置 + 制造"我是不是该记下来"的负担。
                  ⚠️ **模型那边照旧拿得到完整 id** —— 它走 list_assignment_batches,
                  与界面显不显示无关。 */}
            </span>
            {revoked ? (
              // 撤销结果**如实三个数**,⛔ 不能只说"已撤销":客服打开过的单收不回来,
              // 只报一个数会让主管以为整批都回来了
              <span className="text-[11px] text-slate-500">
                已撤销 {revoked.revoked} 条
                {revoked.skippedTouched > 0 && ` · ${revoked.skippedTouched} 条客服已看过,未收回`}
                {revoked.alreadyReleased > 0 && ` · ${revoked.alreadyReleased} 条本就不在手上`}
              </span>
            ) : (
              canRevoke && (
                <Button
                  variant="ghost"
                  size="sm"
                  disabled={revoking}
                  onClick={() => setConfirmRevoke(true)}
                  className="h-6 gap-1 px-2 text-[11px] text-slate-500 hover:text-rose-600"
                >
                  {revoking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Undo2 className="h-3 w-3" />}
                  撤销这批
                </Button>
              )
            )}
          </div>
        ) : (
          <Can
            perm={Permission.PLAN_DISPATCH}
            fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
          >
            {/**
             * ⭐ 确认前顺手问一句**这批带不带福利**（2026-08-19 产品加）。
             *
             * 福利只影响**此后生成**的话术，而确认之后客服就开始陆续打开这批单 ——
             * 这颗按钮是最后一个还来得及的停顿点。
             * ⚠️ 它**不是一道闸**：空着照样确认（浮层里主按钮永远可点）。
             * ⚠️ 外层这颗按钮只当触发器，`onClick` 交给浮层 —— ⛔ 别在这儿也挂一个
             *   `void confirm()`：两条路都会确认，而幂等键相同的第二次请求
             *   服务端会当重放**静默吃掉**，界面上看着像"点了没反应"。
             */}
            <BenefitPopover
              value={benefit}
              count={items.length}
              disabled={items.length === 0}
              submitting={submitting}
              onConfirm={(b) => {
                setBenefit(b);
                void confirm(b);
              }}
            >
              <Button disabled={submitting || items.length === 0} className="w-full gap-1.5">
                {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
                确认分配 {items.length} 条
              </Button>
            </BenefitPopover>
          </Can>
        )}
      </div>

      {/* 撤销整批前的二次确认。
          ⚠️ 措辞必须先说「**客服已经打开过的收不回来**」—— 撤销几乎总是部分成功,
          主管以为"点了就全回来了"的话,他会停止补救(比如不再逐条去打招呼),
          而那几条其实还挂在别人手上。 */}
      <AlertDialog open={confirmRevoke} onOpenChange={(o) => !o && setConfirmRevoke(false)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>撤销这批分配?</AlertDialogTitle>
            <AlertDialogDescription>
              这批 {items.length} 条会退回召回池。
              <span className="font-medium text-slate-700">
                客服已经打开过的单不会被收回
              </span>
              —— 他可能已经联系了患者,收回等于把那通电话抹掉。撤销窗口是确认后{' '}
              {REVOKE_WINDOW_MINUTES} 分钟;过了就走「客服退回」逐条处理。
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={revoking}>不撤了</AlertDialogCancel>
            <Button disabled={revoking} onClick={() => void doRevoke()} className="gap-1.5">
              {revoking && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
              确认撤销
            </Button>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {/* 移除客服前的二次确认 —— ⚠️ 必须说清"他名下的条目会一起移出",
          否则主管以为只是把人从名单上划掉,回头发现少了 20 个患者 */}
      <AlertDialog open={pendingRemove != null} onOpenChange={(o) => !o && setPendingRemove(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>
              把「{removingGroup?.name ?? removingGroup?.userId.slice(0, 8)}」移出本批?
            </AlertDialogTitle>
            <AlertDialogDescription>
              他名下的 {removingGroup?.list.length ?? 0} 条会
              <span className="font-medium text-slate-700">一并移出本批</span>
              (患者留在池子里,下批再说)。想把这些人交给别人,先把条目拖过去,
              或者回对话说一句让助手重出一版。
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>取消</AlertDialogCancel>
            <AlertDialogAction onClick={() => pendingRemove && removeAgent(pendingRemove)}>
              移出本批
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </Card>
  );
}

/**
 * 把确认失败讲成主管能照着做的话。
 *
 * 🔴 服务端对 zod 校验失败回的是一句**笼统**的「请求字段校验失败」(10002),
 *   字段级原因在 `details` 里 —— 而这里原本只取 `e.message`,于是主管看到的
 *   永远是那七个字:不知道哪一项不合格、更不知道该改成多少。
 *   2026-08-20 生产实遇:537 条的确认单撞上 `items.max(500)`,主管在卡片上
 *   反复改都没用,因为**改什么都不影响这条报错**。
 *
 * ⚠️ 只拼 `message`,⛔ 不拼 `path`/`code`:`items`、`too_big` 这类字样对主管
 *   没有意义,只会让他觉得"这是个 bug,不是我能处理的事"。
 */
function describeConfirmError(e: unknown): string {
  const detail = (e as { details?: unknown } | null)?.details;
  if (Array.isArray(detail)) {
    const msgs = detail
      .map((d) => (d as { message?: unknown })?.message)
      .filter((m): m is string => typeof m === 'string' && m.length > 0);
    if (msgs.length > 0) return msgs.join(';');
  }
  return e instanceof Error && e.message ? e.message : '确认失败';
}
