'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
  ASSIGNMENT_EXPIRES_DAYS_PRESETS,
  potentialTreatmentItemName,
  AssignStrategy,
  REVOKE_WINDOW_MINUTES,
  Permission,
  type AgentOverride,
  type AssignmentProposal,
  type ProposedItem,
  type RevokeAssignmentResponse,
  type SheetEditOp,
} from '@pac/types';
import { Can } from '@/components/can';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
 *   上面四件事都是对**已经算好的结果**做局部修正,不产生新的人群。
 *
 * ── 布局 ────────────────────────────────────────────────
 * 助手窗只有 400px 宽(最大化后更宽),所以**不用 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: '改福利',
};

/**
 * 患者姓名 —— **点一下复制**(2026-08-06 产品加)。
 *
 * ⚠️ 主管审这批人时常要把名字贴到别处(问医生、查宿主档案、发消息),
 *   在一个 400px 的列表里手动选中一个中文名很难选准。
 * ⚠️ `stopPropagation` 是必须的:这一行同时是**折叠开关的兄弟**且整行可拖,
 *   不拦住会连带触发别的行为。
 * ⚠️ `draggable={false}`:不加的话按住名字拖会被浏览器当成拖文本,把整行的拖拽吃掉。
 * ⛔ 复制的是**姓名本身**,不含病历号/性别年龄 —— 那些是给眼睛看的上下文,
 *   粘出去多带一串反而要手动删。
 */
function CopyableName({ name }: { name: string }) {
  const [copied, setCopied] = useState(false);
  return (
    <button
      type="button"
      draggable={false}
      title="点击复制姓名"
      onClick={(e) => {
        e.stopPropagation();
        void navigator.clipboard?.writeText(name);
        setCopied(true);
        setTimeout(() => setCopied(false), 1200);
      }}
      className={cn(
        'rounded px-0.5 transition-colors',
        copied ? 'bg-emerald-50 text-emerald-700' : 'hover:bg-brand-50 hover:text-brand-700',
      )}
    >
      {copied ? '已复制' : name}
    </button>
  );
}

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;
}

/**
 * 时效选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
 * 原生下拉在深色/窄屏下样式不可控,而这个控件现在**每一条召回计划都有一个**,
 * 出现几百次,样式必须跟卡片其余部分一致。
 * ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
 */
/**
 * 时效下拉 —— **点了才挂真正的 Radix Select**。
 *
 * 🔴 由来(2026-08-05 实测):原来每一行直接渲染一个 `<Select>`。展开一个 36 条的客服组
 *   = 一次性挂 36 个 Radix Select,每个都带 portal / 焦点管理 / 键盘导航,
 *   展开那一下明显卡顿。而这些下拉**绝大多数根本不会被点** —— 主管一批 200 人,
 *   真正逐条改时效的是个位数。
 *
 * ✅ 平时只是一个 `<button>`(样式与 SelectTrigger 一致),点击才换成真 Select 并自动展开。
 * ⚠️ `defaultOpen` 是关键:换过去之后要立刻弹开,否则用户得点两下。
 * ⚠️ 收起后**不还原成按钮** —— 还原会让"刚改完又想再改"变成再等一次挂载,
 *   而且已经挂上的那一个不再是成本问题(卡的是 36 个同时挂)。
 */
function DaySelect({
  value,
  disabled,
  onChange,
  compact,
}: {
  value: number;
  disabled?: boolean;
  onChange: (days: number) => void;
  /** 条目行用紧凑版(只显示数字,省掉"天"字省宽度) */
  compact?: boolean;
}) {
  const [live, setLive] = useState(false);
  const cls = cn('h-6 gap-1 px-1.5 text-[11px]', compact ? 'w-[54px]' : 'w-[76px]');

  if (!live) {
    return (
      <button
        type="button"
        disabled={disabled}
        onClick={() => setLive(true)}
        // 样式与 SelectTrigger 对齐 —— 换过去时不许有跳动
        className={cn(
          cls,
          'inline-flex items-center justify-between rounded-md border border-input bg-transparent',
          'shadow-xs transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50',
        )}
      >
        <span>{compact ? value : `${value} 天`}</span>
        <ChevronDown className="h-3 w-3 opacity-50" />
      </button>
    );
  }

  return (
    <Select
      value={String(value)}
      disabled={disabled}
      defaultOpen
      onValueChange={(v) => onChange(Number(v))}
    >
      <SelectTrigger className={cls}>
        <SelectValue />
      </SelectTrigger>
      {/* z 层级已在 ui/select.tsx 统一抬到 70(助手窗是 z-60,见那里的层级约定) */}
      <SelectContent>
        {ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
          <SelectItem key={d} value={String(d)} className="text-[12px]">
            {d} 天
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

export function AssignmentConfirmSheet({
  requestId,
  sheet,
  state,
  assignmentId,
  onConfirmed,
  edits,
  onEditApplied,
  onSheetReplaced,
}: {
  requestId: string;
  sheet: AssignmentProposal;
  state: 'pending' | 'confirmed' | 'cancelled';
  assignmentId?: string;
  /** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
  /** `modelSummary` = 同一句话的**喂模型版**(带完整批次 id,界面不显示) */
  onConfirmed: (assignmentId: string, summary: string, modelSummary?: string) => void;
  /**
   * 助手下发的语义编辑指令队列(edit_assignment_sheet)。
   * ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
   */
  edits?: { seq: number; ops: SheetEditOp[] }[];
  /** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
  onEditApplied?: (summary: string) => void;
  /**
   * 「重新排一版」返回的新提案 —— 由外层把消息流里那个 block 换掉。
   * ⚠️ 换的是**整张卡**:人群没变、人换了,主管在旧卡上做的删/改派对新名单没有意义。
   */
  onSheetReplaced?: (next: AssignmentProposal) => void;
}) {
  /**
   * 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
   * 改它 = 把所有**没有单独设过**的条目一起改;单独设过的不动(下面 dayByPlan 优先)。
   */
  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)——
   *   在 400px 的卡片里放一个自由文本框,主管十有八九会写成一段带承诺的话,
   *   而福利话术有硬护栏(不得追加条件/期限/承诺),那道闸在生成侧,不在这里。
   */
  const [benefit, setBenefit] = useState<string>('');
  /**
   * ── 撤销整批(限时 30 分钟)────────────────────────────────
   *
   * ⚠️ 语义是「**手滑 / 分错人**的补救」,不是"改主意重新调度" ——
   *   改主意应该走「客服退回」,那条路有完整的原因记录(T7)。所以限时。
   * ⚠️ 它是 **UX 摩擦不是安全边界**:主管本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
   *   30 分钟后照样能逐条收回来。⛔ 别基于"过了窗口就锁死了"去设计别的东西。
   * ⚠️ 窗口按**确认那一刻**算(confirmedAt),⛔ 不能用提案生成时间:
   *   主管可能盯着确认单改了二十分钟才点确认,那二十分钟不该算进他的补救窗口。
   */
  /// 「待分配」是否展开。⭐ **默认展开** —— 它的存在就是为了引起注意,默认藏起来等于白做
  const [pendingOpen, setPendingOpen] = useState(true);
  /// 「重新排一版」进行中(按钮转圈,防连点)
  const [refilling, setRefilling] = useState(false);
  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);
      // ⭐ 结果**原话**进对话:note 是服务端拼的成品句子(含"已看过的没收回"那半句),
      //   既给主管看,也补进模型上下文 —— 否则它下一轮会说"整批都撤回了"
      onEditApplied?.(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 readOnly = done || cancelled || 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],
  );

  const confirm = async () => {
    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 对象
        ...(benefit.trim() ? { attributes: { benefit: { text: benefit.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());
      // 🔴 模型那份**必须带完整 uuid**:它要撤销只能靠这里拿 id。
      //   此前这句写的是 `批次 #${id.slice(0,8)}`,两边共用 —— 于是模型手里只有 8 位短号,
      //   一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的,
      //   两条路测一条就漏了(实测 2026-08-03)。
      //   ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。
      /**
       * ⭐ 没配福利 → 就地提醒一句"还能补挂"(2026-08-06 产品加的)。
       *
       * 为什么是这一刻:福利只影响**此后生成**的话术,而客服正是从现在开始陆续打开这批单
       * —— 再往后每过一会儿,能用上福利的人就少一个。确认这一下是最后一个自然时机。
       *
       * 🔴 **必须写进界面那份(可见文案),⛔ 不能只写进模型那份。**
       *   `appendAssistantNote` 只是往消息流里塞一段文字,**不会触发模型新一轮发言** ——
       *   只给模型看,等于主管永远看不到这句提醒(实测:确认完对话里一个字都没多)。
       *   同时也给模型那份留一句状态,他回头问「这批带福利没」时才答得上来。
       *
       * ⛔⛔ **不许写"带福利成功率更高 / 转化率提升"** —— 本系统**不统计成功**
       *   (助手硬约束第 4 条),那个结论编不出来。只讲事实:福利会进话术、当开口的由头。
       * ⚠️ 措辞要短:主管刚点完确认,这里是顺带一提,不是推销。
       */
      const benefitNudge = benefit.trim()
        ? ''
        // ⚠️ 必须是**两个换行**:这段走 ReactMarkdown 渲染,单个 \n 会被折叠掉,
        //   结果是「…3 天有效 这批还没带福利 ——」挤成一行,读起来像上一句的尾巴(实测)。
        : '\n\n这批还没带福利 —— 现在说一句「这批带上『老客户复查免挂号费』」我就写进去,' +
          '客服生成话术时会拿它当开口的由头;不需要就不用管。';
      onConfirmed(
        res.assignmentId,
        summary + benefitNudge,
        `${summary}(批次 id ${res.assignmentId} —— 主管要撤销这批时,把这个完整 id 传给 revoke_assignment)` +
          (benefitNudge
            ? benefitNudge +
              '\n⚠️ 上面这句提醒**界面已经对主管说过了**,⛔ 你不要再重复一遍;' +
              '他说「带上…」时你调 set_benefit,说不用就别再提。'
            : ''),
      );
    } catch (e) {
      setError(e instanceof Error ? e.message : '确认失败');
    } finally {
      setSubmitting(false);
    }
  };

  /** 移除某个客服 = 他名下的条目**一并移出本批** */
  const removeAgent = (userId: string) => {
    setDropped((s) => {
      const n = new Set(s);
      for (const it of items) if (it.assignee === userId) n.add(it.planId);
      return n;
    });
    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[] = [];
    // ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术),
    //   而且必须走接口真写库;人员 / 时效那些动的是已经发到客服手上的单,只能走撤销重分。
    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)。
                onEditApplied?.(`确认单已更新:${r.note}`);
              })
              .catch((e) =>
                onEditApplied?.(
                  `福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`,
                ),
              );
            continue;
          }
          notes.push(
            `这批**已经确认分配**了,「${OP_ZH[op.action] ?? op.action}」改不了 —— ` +
              `单子已经在客服手上。要调整请撤销这批重新分(限 30 分钟),或让客服自己退回`,
          );
        }
      }
      if (notes.length) onEditApplied?.(`确认单没有改动:${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);
              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}」还是患者`,
              );
              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) onEditApplied?.(`确认单已更新:${notes.join(';')}。`);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [edits]);

  /// 找到离卡片最近的可滚动祖先(助手窗的消息区),拖拽自动滚动用
  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="overflow-hidden border-brand-100 text-[12px] shadow-sm"
    >
      {/* ① 汇总 */}
      <div className="flex items-center gap-2 border-b border-slate-100 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 条**不会落库**,算进去就是虚报。
        */}
        <span className="font-semibold text-slate-800">
          {pendingList.length > 0 ? (
            // ⚠️ 每段各自 nowrap:400px 的面板里必然换行,不锁的话会断成「待分配 3 / 9」,
            //   数字被劈成两行 —— 断要断在「·」上,不能断在数字中间。
            <>
              <span className="whitespace-nowrap">共 {items.length + pendingList.length} 人</span>
              <span className="whitespace-nowrap">
                {' '}· 拟分 {items.length}({groups.length} 位客服)
              </span>
              <span className="whitespace-nowrap text-amber-800">
                {' '}· 待分配 {pendingList.length} 人
              </span>
            </>
          ) : (
            <>
              拟分 {items.length} 人 · {groups.length} 位客服
            </>
          )}
        </span>
        {dropped.size > 0 && (
          <Badge variant="secondary" className="text-[10px] font-normal">
            已移除 {dropped.size}
          </Badge>
        )}
        {sheet.unplaced > 0 && (
          <Badge variant="secondary" className="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>
        )}
        {/* ⭐ 批次时效放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
            放在底部微调区会让人以为它跟下面的逐条时效是一回事。
            ⚠️ 文案是「N 天**后自动退回**」而不是光一个「时效」——
            主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */}
        <div className="ml-auto flex flex-none items-center gap-1 text-[10.5px] text-slate-500">
          <DaySelect value={expiresInDays} disabled={readOnly} onChange={setExpiresInDays} />
          <span>后自动退回</span>
        </div>
      </div>

      {/*
        ①.5 **「重新排一版」做了什么** —— 紧贴汇总行,⛔ 不能放到卡片底部。
        🔴 由来(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">
          {sheet.refillNote.split('**').map((seg, i) =>
            i % 2 === 1 ? (
              <strong key={i} className="font-semibold">
                {seg}
              </strong>
            ) : (
              <span key={i}>{seg}</span>
            ),
          )}
        </div>
      )}

      {/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
      <CardContent className="p-0">
        <div className="divide-y divide-slate-50">
          {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 }));
                }}
                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>
                  {!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 }))}
                        />
                        {!readOnly && (
                          <Button
                            variant="ghost"
                            size="icon"
                            title="本批不分这个人"
                            onClick={() => setDropped((s) => new Set(s).add(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 && (
        <div className="border-t-2 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。⛔ 它**不动任何人的专属关系** —— 这是底线,不是可选项。
              ⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。
            */}
            {!readOnly && sheet.canRefill && (
              <div className="mt-1 pl-[18px]">
                <Button
                  size="sm"
                  variant="outline"
                  disabled={refilling}
                  onClick={async () => {
                    setRefilling(true);
                    try {
                      // ⚠️ 条件原样带回 —— 少带一个,重排就退回"沿用上一次",人数悄悄变了
                      const next = await assignmentsApi.refill({
                        clinicId: sheet.clinicId,
                        potentialTreatment: sheet.potentialTreatment,
                        temperature: sheet.temperature,
                        targetCount: sheet.batchSize,
                        expiresInDays,
                      });
                      onSheetReplaced?.(next);
                    } catch (e) {
                      setError(e instanceof Error ? e.message : '重新排版失败');
                    } finally {
                      setRefilling(false);
                    }
                  }}
                  className="h-6 border-amber-300 bg-white px-2 text-[11px] text-amber-800 hover:bg-amber-50"
                >
                  {refilling ? '正在重排…' : '重新排一版,尽量排满'}
                </Button>
              </div>
            )}
          </div>
          {pendingOpen && (
          <ul className="space-y-px 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}
                {/* ⭐ 「为什么卡住」的证据:是谁的专属 + 他现在手上多少。
                    ⛔ 别只写「专属已满」—— 主管要判断的正是"这个人该不该从他手里挪走"。 */}
                <span className="flex-none text-[10px] text-amber-700">
                  专属 {p.ownerName ?? p.ownerUserId.slice(0, 6)}(在手 {p.ownerInHand})
                </span>
                {!readOnly && (
                  <Button
                    variant="ghost"
                    size="icon"
                    title="移出本批"
                    onClick={() => setDropped((sset) => new Set(sset).add(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 border-slate-100 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-06 走查从对话里搬来)。
        
        原来要求助手每出一版都在消息里念一遍。第一次有用,而主管一天要看好几版,
        第二次起全是噪音 —— 那段话比确认单本身还长。
        ⭐ 放在这里:不占对话流、永远在需要时看得见,而且**紧挨着确认按钮**
          (他犹豫要不要点的那一刻,正好看到"原来还能这么调")。
        ⚠️ 只在**还能改**的时候显示:已确认 / 已撤销时它是误导。
      */}
      {!readOnly && (
        <div className="border-t border-slate-100 bg-slate-50/60 px-3 py-1.5 text-[10.5px] leading-relaxed text-slate-400">
          可以直接跟我说:「把某某移出这批」「某某的单给 5 天」「这批 200 人」·
          「只要商保直付的」「排掉最近来过的」· 「这批带上『老客户复查免挂号费』」
        </div>
      )}

      {/* ④ 确认 */}
      <div className="border-t border-slate-100 px-3 py-2">
        {error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
        {cancelled && !revoked ? (
          // 助手撤的:本地没有三个数,只说状态 —— 具体数字助手在消息里已经原话转述过
          <span className="text-[11.5px] font-medium text-slate-500">这批已撤销</span>
        ) : done ? (
          <div className="flex items-center gap-2">
            <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>}
          >
            <Button
              disabled={submitting || items.length === 0}
              onClick={() => void confirm()}
              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>
          </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>
  );
}
