Commit 7cfeb862 by luoqi

feat(web): 确认单重做 —— 逐条时效/删除/拖拽改派/移除客服,全部走 shadcn

产品走查:时效应该落到每一条召回计划;条目要能删、能拖给别的客服;客服要能移除;
整体用 shadcn 组件承载。

· 布局全部换成 shadcn:Card / Badge / Button / Select / AlertDialog。
· **时效逐条**:每条召回计划一个 1-7 天下拉,批次级那个只是"没单独设过的默认"。
  落库时只在与批次不同时带 expiresInDays(一样也带 = 把批次时效冻进每条,
  将来批次层面改时效就改不动这些单子了)。
· **单条删除** + **移除客服**(AlertDialog 二次确认,明说"他名下的条目一并移出本批")。
· **拖拽改派**:原生 HTML5 DnD( 不引 DnD 库),整个客服块都是投放区 ——
  展开后列表很长,只认标题行会让人反复试。拖过的落库时 assignStrategy 改成 **manual**,
  不改的话事后分析会把它算成算法的选择,而 T20 要反推的正是"算法选得准不准"。
· 所有展示改从**生效条目**派生(sheet.items − 删掉的 + 改派 + 逐条时效),
   不再直接用 sheet.byAgent —— 那是提案时的分组,拖过一条就对不上了。

🔴 修一个"改了不报错、但功能等于没有"的坑:助手窗 fixed z-[60],而 shadcn SelectContent
自带 z-50,Radix 会把 content 的 z 复制到 body 上那层 popper wrapper → 下拉渲染在助手窗
**底下**:listbox 确实打开了(DOM 里查得到)、但一个选项都看不见。实测才发现,已抬到 z-[70]
并写进教条(凡在助手窗里用 Radix 浮层都要抬 z)。

本地实测:删一条 → 薛玫 20→19 且在手 0→19;改一条时效 3→5 天只影响那一条;
下拉 1-7 天全部可见可选。989 tests green。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 14ec24ec
'use client';
import { useState } from 'react';
import { ChevronRight, Check, Loader2, Users } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Check, ChevronRight, GripVertical, Loader2, Users, X } from 'lucide-react';
import {
ASSIGN_STRATEGY_META,
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
AssignStrategy,
Permission,
type AgentOverride,
type AssignmentProposal,
type AssignStrategy,
type ProposedItem,
} 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 } from '@/lib/utils';
/**
......@@ -21,68 +41,94 @@ import { cn } from '@/lib/utils';
* artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`,
* **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
*
* ── 微调**只有两类**(T13)──────────────────────────────────
* 指定客服、时效(整批 + 按客服两级)。多加一类就直接违 T13 ——
* 微调项越多,「一次确认」这个设计目标就越不可能实现。
* 要换人群请回到对话里让助手重新圈,不要在卡片上做筛选。
* ── 卡片上能做什么(2026-08-03 产品定)──────────────────────────
* 四件**局部**操作,都不需要重新圈人、也不需要重跑落人算法:
* ① 逐条改时效(1-7 天) ② 删掉某一条
* ③ 把某一条拖给别的客服 ④ 移除某个客服(连同他名下的条目)
* ⛔ 换人群、改批次人数、按条件重新收敛 —— 一律回对话让助手重出一版。
* 判据很简单:**要不要重跑服务端算法**。要,就回对话;不要,才放到卡片上。
*
* ⚠️ 这不违 T13「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
* 上面四件事都是对**已经算好的结果**做局部修正,不产生新的人群。
*
* ── 布局 ────────────────────────────────────────────────
* 助手窗只有 400px 宽,所以**不用 table**(横向撑爆),用卡片列表;
* 助手窗只有 400px 宽(最大化后更宽),所以**不用 table**(横向撑爆),用卡片列表;
* 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
*/
/** 卡片本地状态下,一条召回计划的**生效**形态(叠加了主管的改动) */
interface EffectiveItem extends ProposedItem {
/** 生效的经办人(拖拽后可能不是提案里的那个) */
assignee: string;
/** 生效时效(天) */
days: number;
/** 是否被主管拖到了别人名下 —— 落库时策略要改成 manual */
moved: boolean;
}
/**
* 精调表落库形态 = 提案带来的(含名额上限部分) + 卡片上刚改的时效
* 精调表落库形态 = 提案带来的名额上限部分(原样带走)
*
* ⚠️ 卡片只能改时效,所以**名额上限部分必须原样带走** —— 少带一次,主管上次设的
* 「李莉这批最多 5 条」就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
* ⚠️ 时效**不再进这里** —— 它已经细到"每一条召回计划"一级,再往 agentOverrides
* 里塞一个"按客服的时效"就是两套口径打架(同一个人两处不同的值,听谁的?)。
* ⚠️ 名额上限必须原样带走:少带一次,主管上次设的「李莉这批最多 5 条」
* 就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
*/
function mergeOverrides(
fromSheet: Record<string, AgentOverride>,
expiryByAgent: Record<string, number>,
): Record<string, AgentOverride> {
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 };
}
for (const [userId, days] of Object.entries(expiryByAgent)) {
out[userId] = { ...(out[userId] ?? {}), expiresInDays: days };
}
return out;
}
/**
* 时效选择 —— 1~7 天下拉。
*
* ⭐ 用原生 `<select>`:卡片跑在 400px 的助手窗里,7 个档位铺成按钮会换行占两行,
* 而这是个"设一次就不动"的控件,不值得占那么多地方。
* 时效选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
* 原生下拉在深色/窄屏下样式不可控,而这个控件现在**每一条召回计划都有一个**,
* 出现几百次,样式必须跟卡片其余部分一致。
* ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
*/
function DaySelect({
value,
disabled,
onChange,
compact,
}: {
value: number;
disabled?: boolean;
onChange: (days: number) => void;
/** 条目行用紧凑版(只显示数字,省掉"天"字省宽度) */
compact?: boolean;
}) {
return (
<select
value={value}
<Select
value={String(value)}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className={cn(
'rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px] text-slate-700 outline-none',
'focus:border-brand-400 disabled:opacity-60',
)}
onValueChange={(v) => onChange(Number(v))}
>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<SelectTrigger
className={cn(
'h-6 gap-1 px-1.5 text-[11px]',
compact ? 'w-[54px]' : 'w-[76px]',
)}
>
<SelectValue />
</SelectTrigger>
{/*
🔴 `z-[70]` 不是随手加的:助手窗是 `fixed z-[60]`,而 shadcn 的 SelectContent 自带 `z-50`,
Radix 会把 content 的 z-index **复制到 body 上那层 popper wrapper** —— 于是下拉
以 z-50 渲染在 z-60 的助手窗**底下**:点了有反应(listbox 确实打开了)、但一个选项都看不见。
⚠️ 这类错**不报任何错**,实测才发现(2026-08-03)。凡是在助手窗里用 Radix 浮层的地方,
都要把 z 抬到 60 以上。
*/}
<SelectContent className="z-[70]">
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<SelectItem key={d} value={String(d)} className="text-[12px]">
{d}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
......@@ -101,31 +147,67 @@ export function AssignmentConfirmSheet({
onConfirmed: (assignmentId: string, summary: string) => void;
}) {
/**
* 时效初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3 ——
* 写死的话"记住上次时效"这件事在界面上就永远看不见,主管每次还得重调一遍。
* ⚠️ **本批人数**不做成卡片控件:改人数会换一批人,而卡片的微调项只允许
* "不改人群"的那些(T13)。改人数回对话说一句(「这批 200 人」),助手重出单。
* 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 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);
const done = state === 'confirmed';
const readOnly = done || submitting;
/**
* 按客服的**时效精调**(userId → 天数)。初值来自提案(沿用上次的精调)。
*
* ⚠️ 为什么时效能在卡片上精调、人数/名额不能:时效**不改人群**(只是这几条单子多久回池),
* 另两个会 —— 改了就是换一批人,那必须重出确认单。
* ⚠️ 落库时写到该客服名下**每一条**任务的 `assignment_expires_at`(逐条覆盖批次时效)。
* 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时效。
* ⭐ 所有展示都从这里派生(条数、按客服分组、确认按钮上的数字),
* ⛔ 别再直接用 `sheet.byAgent` —— 它是**提案时**的分组,拖过一条就对不上了。
*/
const [expiryByAgent, setExpiryByAgent] = useState<Record<string, number>>(() =>
Object.fromEntries(
sheet.byAgent
.filter((a) => a.expiresInDays !== sheet.expiresInDays)
.map((a) => [a.userId, a.expiresInDays]),
),
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,
})),
[sheet.items, dropped, moveByPlan, dayByPlan, expiresInDays],
);
const done = state === 'confirmed';
/** 按客服分组(顺序沿用提案里的客服顺序;拖过去的新客服排在后面) */
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)),
];
const nameOf = new Map(sheet.byAgent.map((a) => [a.userId, a.name]));
const inHandOf = new Map(sheet.byAgent.map((a) => [a.userId, a.inHandBefore]));
return order.map((userId) => ({
userId,
name: nameOf.get(userId) ?? null,
inHandBefore: inHandOf.get(userId) ?? 0,
list: byUser.get(userId)!,
}));
}, [items, sheet.byAgent]);
const confirm = async () => {
setSubmitting(true);
......@@ -142,35 +224,38 @@ export function AssignmentConfirmSheet({
candidateTotal: sheet.candidateTotal,
target: sheet.target,
selectionNote: sheet.selectionNote,
// ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
// ⚠️ 存的是**基数**不是 target —— 候选不够时 target 被压低,那是这一批的偶然事实。
// 存 target 的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人。
// ⚠️ 同时 target 也在(上面)—— 沿用时两个键都认,改版不丢"沿用"。
batchSize: sheet.batchSize,
expiresInDays,
// ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值)
agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent),
agentOverrides: carryOverrides(sheet.agentOverrides),
// 主管在卡片上做过的局部修正 —— 事后要能解释"这批为什么跟助手算的不一样"
manualEdits: {
dropped: [...dropped],
moved: Object.keys(moveByPlan).length,
perItemExpiry: Object.keys(dayByPlan).length,
},
},
expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案 —— 主管改了时效就得按改后的走
// ⚠️ 落库的是**卡片当前值**,不是模型原提案。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时效冻进每条,
// 将来批次层面改时效(撤销重发之类)就改不动这些单子了。
items: sheet.items.map((it) => ({
items: items.map((it) => ({
planId: it.planId,
assigneeUserId: it.assigneeUserId,
assignStrategy: it.assignStrategy,
assigneeUserId: it.assignee,
// ⭐ 拖过的标 manual(主管指定)—— 不标的话事后分析会把它算成算法的选择,
// 而 T20 要反推的正是"算法选得准不准"
assignStrategy: it.moved ? AssignStrategy.MANUAL : it.assignStrategy,
selectionMode: it.selectionMode,
...(expiryByAgent[it.assigneeUserId] != null &&
expiryByAgent[it.assigneeUserId] !== expiresInDays
? { expiresInDays: expiryByAgent[it.assigneeUserId] }
: {}),
...(it.days !== expiresInDays ? { expiresInDays: it.days } : {}),
})),
});
const summary =
`已确认分配:批次 #${res.assignmentId.slice(0, 8)} · ` +
(sheet.potentialTreatment ? `${sheet.potentialTreatment} · ` : '') +
`${res.assigned} 条 · ${sheet.byAgent.length} 位客服 · ${expiresInDays} 天有效` +
`${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` +
(dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') +
(res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
onConfirmed(res.assignmentId, summary);
} catch (e) {
......@@ -180,161 +265,211 @@ export function AssignmentConfirmSheet({
}
};
const byPlan = new Map(sheet.items.map((i) => [i.planId, i]));
/** 移除某个客服 = 他名下的条目**一并移出本批** */
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);
return (
<div className="rounded-lg border border-brand-100 bg-white text-[12px] shadow-sm">
<Card 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" />
<span className="font-semibold text-slate-800">
拟分 {sheet.placed} 人 · {sheet.byAgent.length} 位客服
拟分 {items.length} 人 · {groups.length} 位客服
</span>
{dropped.size > 0 && (
<Badge variant="secondary" className="text-[10px] font-normal">
已移除 {dropped.size}
</Badge>
)}
{sheet.unplaced > 0 && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10.5px] text-amber-700">
<Badge variant="secondary" className="bg-amber-100 text-[10px] font-normal text-amber-700">
{sheet.unplaced} 人分不下去
</span>
</Badge>
)}
</div>
{/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
<div className="divide-y divide-slate-50">
{sheet.byAgent.map((a) => {
const isOpen = open.has(a.userId);
const mine = sheet.items.filter((i) => i.assigneeUserId === a.userId);
return (
<div key={a.userId}>
<button
type="button"
onClick={() =>
setOpen((s) => {
const n = new Set(s);
if (n.has(a.userId)) n.delete(a.userId);
else n.add(a.userId);
return n;
})
}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-slate-50"
<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')}
>
<ChevronRight
className={cn('h-3 w-3 flex-none text-slate-400 transition-transform', isOpen && 'rotate-90')}
/>
{/* 姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,400px 卡片会撑爆 */}
<span className="flex-1 truncate font-medium text-slate-700">
{a.name ?? `#${a.userId.slice(0, 8)}`}
</span>
<span className="flex-none tabular-nums text-slate-500">
{a.count}
{a.inHandBefore > 0 && (
<span className="ml-1 text-[10.5px] text-slate-400">(在手 {a.inHandBefore})</span>
)}
</span>
{/* ⛔ 这里原来有个「铺平 N」标 —— 撤掉(2026-08-03 走查:主管看不懂)。
"铺平"是内部算法词,不是业务语言;而且它要表达的事(这几条不是他的老客户)
在展开的患者列表里用「专属」标已经说清楚了 —— 有标的是他的人,没标的不是。
⚠️ 数据没丢:assignStrategy 仍逐条落库,T20 反推照常(T15 要的是"可见",
不是"必须以这个词出现在汇总行")。 */}
{/* ⭐ 精调过的人必须一眼看得出 —— 一条上个月的临时精调如果静默沿用,没人会发现 */}
{(a.overridden || expiryByAgent[a.userId] != null) && (
<span className="flex-none rounded bg-violet-50 px-1.5 py-0.5 text-[10px] text-violet-700 ring-1 ring-inset ring-violet-200">
精调
</span>
)}
</button>
{isOpen && (
<div className="bg-slate-50/60 px-3 pb-2 pl-8">
{/* 按客服的时效精调 —— 落到他名下**每一条**任务上。
容量只读:改它会换人群,得回对话让助手重出单(见组件顶部注释)。 */}
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[10.5px] text-slate-500">
<span>
在手 {a.inHandBefore}<span className="text-slate-700">{a.loadAfter}</span>
</span>
<span className="text-slate-300">·</span>
<span>时效</span>
<DaySelect
value={expiryByAgent[a.userId] ?? expiresInDays}
disabled={done}
onChange={(d) =>
setExpiryByAgent((s) => {
const n = { ...s };
// 选回批次时效 = 取消精调(⛔ 不要留一条"恰好等于批次"的精调,
// 否则主管改批次时效时这个人不跟着动,而卡片上看不出为什么)
if (d === expiresInDays) delete n[a.userId];
else n[a.userId] = d;
return n;
})
}
<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 min-w-0 flex-1 items-center gap-2 text-left"
>
<ChevronRight
className={cn(
'h-3 w-3 flex-none text-slate-400 transition-transform',
isOpen && 'rotate-90',
)}
/>
{expiryByAgent[a.userId] != null && (
<span className="text-violet-600">单独设定</span>
)}
</div>
<ul className="space-y-0.5 text-[11px] text-slate-500">
{mine.slice(0, 30).map((i) => (
<li key={i.planId} className="flex items-center gap-1.5">
{/* ⭐ 姓名优先;宿主没给姓名才退回 id 前缀(⛔ 不显示完整 uuid,卡片只有 400px) */}
<span className="truncate text-slate-700">
{i.patientName ?? `#${i.patientId.slice(0, 8)}`}
{/* 姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,卡片会撑爆 */}
<span className="truncate font-medium text-slate-700">
{g.name ?? `#${g.userId.slice(0, 8)}`}
</span>
<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>
{i.medicalRecordNumber && (
<span className="flex-none font-mono text-[10px] text-slate-400">
{i.medicalRecordNumber}
</span>
)}
{/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂(2026-08-03 走查)。
没有标的就是"不是他的老客户",反而更好读。 */}
{ASSIGN_STRATEGY_META[i.assignStrategy as AssignStrategy]?.isSpread ===
false && (
<span className="flex-none rounded bg-brand-50 px-1 text-[10px] text-brand-700">
专属
</span>
)}
{i.selectionMode === 'explore' && (
<span className="flex-none rounded bg-violet-100 px-1 text-[10px] text-violet-600">
探索
</span>
)}
</li>
))}
{mine.length > 30 && (
<li className="text-[10px] text-slate-400">…另有 {mine.length - 30}</li>
</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>
)}
</ul>
</div>
)}
</div>
);
})}
</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">
{i.patientName ?? `#${i.patientId.slice(0, 8)}`}
{i.medicalRecordNumber && (
<span className="ml-1 font-mono text-[10px] text-slate-400">
{i.medicalRecordNumber}
</span>
)}
</span>
{/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂。
没标的就是"不是他的老客户",反而更好读。 */}
{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>
{/* 本批没分到的人 —— 仍然列出来:主管要看见"他不是被漏了"。
⚠️ 措辞不能写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
(水位法没轮到)或被精调成 0 名额。写「已满(0)」会自相矛盾。 */}
⚠️ 措辞不能写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
(水位法没轮到)或被精调成 0 名额。 */}
{sheet.skippedAgents.length > 0 && (
<div className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">
本批未分到:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
</div>
)}
{/* ③ 微调 —— 仍然**只有两类**(T13):指定客服、时效。
时效有两级:这里是整批默认,展开某个客服可以单独给他一个(⚠️ 仍是"时效"这一项,
不是第三项);容量只读,改它会换人群 → 回对话让助手重出单。 */}
{/* ③ 批次级设置 */}
<div className="space-y-1.5 border-t border-slate-100 px-3 py-2">
<div className="flex items-center gap-2">
<span className="flex-none text-slate-500">时效</span>
{/* ⭐ 1-7 天下拉,不是 3/5/7 三档 —— 档位是我们拍的,主管对自己团队的节奏有判断 */}
<DaySelect value={expiresInDays} disabled={done} onChange={setExpiresInDays} />
<span className="text-[10px] text-slate-400">每条召回计划各自记这个到期</span>
<DaySelect value={expiresInDays} disabled={readOnly} onChange={setExpiresInDays} />
{/* ⭐ T14:这个初值哪来的必须当场说清 —— 沿用上次 / 首次默认,信任度不同 */}
<span className="text-[10px] text-slate-400">
{sheet.basis === 'inherited' && sheet.basisFrom
? `沿用 ${sheet.basisFrom.slice(5, 10).replace('-', '/')} 那次`
: sheet.basis === 'explicit'
? '本次指定'
: '首次默认(暂无历史结案数据)'}
: '首次默认'}
{Object.keys(dayByPlan).length > 0 && ` · ${Object.keys(dayByPlan).length} 条单独设了`}
</span>
</div>
{/* 本批人数:**只读**。改它就是换一批人 —— 卡片不做这种事(T13) */}
{/* 本批人数:**只读**。改它就是换一批人 —— 卡片不做这种事(要回对话) */}
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="flex-none">人数</span>
<span className="rounded border border-slate-200 px-2 py-0.5 tabular-nums text-slate-600">
......@@ -343,11 +478,12 @@ export function AssignmentConfirmSheet({
<span className="text-[10px] text-slate-400">
{sheet.target < sheet.batchSize
? `候选只有这么多(基数 ${sheet.batchSize} 不变)`
: `要改说「这批 ${sheet.batchSize * 2} 人」· 池子里还有人随时能再分一批`}
: `要改说「这批 ${sheet.batchSize * 2} 人」`}
</span>
</div>
<p className="text-[10.5px] leading-relaxed text-slate-400">
指定客服请在对话里说(如「王强这批给李莉」);换人群也回对话让助手重新圈。
条目可<span className="font-medium text-slate-500">拖到别的客服</span>名下;
换人群 / 改人数回对话让助手重新圈。
</p>
</div>
......@@ -371,18 +507,41 @@ export function AssignmentConfirmSheet({
perm={Permission.PLAN_DISPATCH}
fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
>
<button
type="button"
disabled={submitting || sheet.placed === 0}
<Button
disabled={submitting || items.length === 0}
onClick={() => void confirm()}
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-brand-600 px-3 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
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" />}
确认分配 {sheet.placed}
</button>
确认分配 {items.length}
</Button>
</Can>
)}
</div>
</div>
{/* 移除客服前的二次确认 —— ⚠️ 必须说清"他名下的条目会一起移出",
否则主管以为只是把人从名单上划掉,回头发现少了 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>
);
}
......@@ -132,6 +132,32 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
**确认单的患者明细必须给姓名 + 病历号,⛔ 不许只给 id 前缀。**
主管展开客服看的就是"这 20 个人是谁",给一串 `#912e5dae` 等于让他对着十六进制猜(T14)。
### 确认单上能做什么(2026-08-03 定)
四件**局部**操作,判据是**要不要重跑服务端算法**:不要,才放到卡片上。
| 卡片上做 | 回对话做 |
|---|---|
| 逐条改时效(1-7 天) | 换人群(换治疗项 / 温度 / 画像条件) |
| 删掉某一条 | 改批次人数 N |
| 把某一条**拖**给别的客服 | 按客服设本批名额 `maxThisBatch` |
| 移除某个客服(连同他名下条目) | |
⚠️ 这不违 T13「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
上面四件都是对**已经算好的结果**做局部修正,不产生新的人群、不重跑落人算法。
⚠️ 拖过的条目落库时 `assignStrategy` 改成 **`manual`**(主管指定)——
不改的话事后分析会把它算成算法的选择,而 T20 要反推的正是"算法选得准不准"。
⚠️ 移除客服 = **他名下的条目一并移出本批**(患者留在池子里,下批再说),
必须有二次确认并把这句话写在弹窗里 —— 否则主管以为只是把人从名单上划掉,
回头发现少了 20 个患者。要把这些人交给别人:先拖走,或回对话让助手重出一版。
> 🔴 **踩过的坑**:助手窗是 `fixed z-[60]`,而 shadcn 的 `SelectContent` 自带 `z-50`,
> Radix 会把 content 的 z-index **复制到 body 上那层 popper wrapper** ——
> 下拉以 z-50 渲染在助手窗**底下**:点了有反应(listbox 确实打开了)、但一个选项都看不见,
> **不报任何错**。凡是在助手窗里用 Radix 浮层,z 都要抬到 60 以上。
> **弯路一**:曾把「客服不能接别人的专属患者」做成**硬约束**(给不下就 unplaced)。
> 结果在上面那份数据下,「满」和「平」不可能同时成立 ——
> 严格齐平的上限是每人 9 条、一批只有 153 人,而那 755 个患者要几十批才轮得完。
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment