Commit 70d53a9d by luoqi

feat(plan-detail): supersede 老 URL 自动跳最新版 + 刷新按钮旁加"更新于"

问题:刷新/重算会 supersede 出新版本 plan(新 id),老 plan 行仍保留
冻结快照。getPlanDetailByPlanId 用 findUnique(id) 照样返回 superseded 的
旧数据(不 404),导致老 plan URL(刷新后 refetch / 收藏 / 分享 / 任务抽屉)
停在陈旧快照 —— 召回原因读老 plan 的 1;4,治疗链实时合成 1B;4C,对不上 →
"暂不召回",看着像"刷了没变化"。

修复:
- 后端 getPlanDetailByPlanId 检测请求的 plan 为 superseded → 解析该患者当前
  active/assigned plan → 响应带 currentPlanId(否则 = 请求 id);serializePlan
  增加 updatedAt。
- 前端 page 据 currentPlanId !== planId 时 router.replace 落到最新版(过场
  提示,不渲染陈旧快照),通吃所有老 URL 入口。
- 刷新按钮左侧加"更新于 X"(plan.updatedAt 相对时间),给数据新鲜度。

验证:老 plan(superseded)→ currentPlanId=新版;新版 K00 chain.target=true
(★潜在新链)、reason=1B;4C。service + web tsc 均通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent bcf26264
......@@ -39,7 +39,28 @@ export class PlanAggregateService {
include: { profile: true },
});
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
return this.assemble(scope, patient, plan);
// supersede 跳转:plan 重算后会 supersede 出新版本(新 id),老 plan 行仍保留冻结快照。
// 老 plan URL(收藏 / 分享 / 任务抽屉 / 刷新后 refetch)若直接渲染会显示陈旧数据。
// → 检测到请求的是 superseded plan,解析该患者当前 active/assigned plan,响应带 currentPlanId,
// 前端 router.replace 落到最新版(单 patient 仅一个活跃 plan,partial UNIQUE 保证)。
let currentPlanId = plan.id;
if (plan.status === 'superseded') {
const active = await this.prisma.followupPlan.findFirst({
where: {
patientId: plan.patientId,
hostId: scope.hostId,
tenantId: scope.tenantId,
status: { in: ['active', 'assigned'] },
},
orderBy: { version: 'desc' },
select: { id: true },
});
if (active) currentPlanId = active.id;
}
const assembled = await this.assemble(scope, patient, plan);
return { ...assembled, currentPlanId };
}
// ─────────────────────────────────────────────
......@@ -269,6 +290,7 @@ function serializePlan(plan: {
assigneeUserId: string | null;
assignedAt: Date | null;
recycleAt: Date | null;
updatedAt: Date;
reasons: Array<{
id: string;
scenario: string;
......@@ -291,6 +313,8 @@ function serializePlan(plan: {
maxContactAttempts: 4, // 应用层默认(后续可挂 host 配置)
targetClinicId: plan.targetClinicId,
goal: plan.goal,
/// 该 plan 版本的重算时间 — UI "更新于 X" 渲染数据新鲜度
updatedAt: plan.updatedAt.toISOString(),
recommendedAt: plan.recommendedAt?.toISOString() ?? null,
recommendedRole: plan.recommendedRole,
recommendedChannel: plan.recommendedChannel,
......
......@@ -57,6 +57,17 @@ function PlanDetailLoader({ planId }: { planId: string }) {
}
}, [state, router]);
// supersede 跳转:刷新/重算后 plan 可能升级出新版本(新 id),老 plan URL 仍能查到
// 冻结快照 → 会显示陈旧数据。后端在 superseded 时回传 currentPlanId(该患者当前活跃版本),
// 这里 replace 落到最新版,通吃刷新按钮 / 收藏 / 分享 / 任务抽屉等所有老 URL 入口。
const redirectTo =
state.status === 'ready' && state.data.currentPlanId !== planId
? state.data.currentPlanId
: null;
useEffect(() => {
if (redirectTo) router.replace(`/plans/${redirectTo}`);
}, [redirectTo, router]);
if (state.status === 'loading' || state.status === 'idle') {
return (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground">
......@@ -96,6 +107,15 @@ function PlanDetailLoader({ planId }: { planId: string }) {
);
}
// supersede 跳转中:不渲染老版快照,显示过场(避免陈旧数据一闪)
if (redirectTo) {
return (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground">
该任务已更新,正在跳转到最新版本…
</div>
);
}
// 直接渲染详情;返回按钮挂在 PlanDetailApp 内部 TopBar(后续如需再加)
// 左侧"我的任务"沉浸抽屉(hover 滑出 + 触底分页 + 跨任务跳转)
return (
......
......@@ -138,6 +138,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
},
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
updatedAt: real.plan?.updatedAt ? new Date(real.plan.updatedAt) : null,
recommendedAt: real.plan?.recommendedAt ? new Date(real.plan.recommendedAt) : now,
recommendedRole: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
recommendedChannel: (real.plan?.recommendedChannel as ExecutionChannel) ?? ExecutionChannel.PHONE,
......
......@@ -285,6 +285,7 @@ export const mockPlan = {
assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole },
assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
updatedAt: NOW as Date | null,
recommendedAt: NOW,
recommendedRole: UserRole.STAFF as UserRole,
recommendedChannel: ExecutionChannel.PHONE as ExecutionChannel,
......
......@@ -207,6 +207,7 @@ export function PlanDetailApp({
patientId={patient.id}
onRefreshAggregate={onRefreshAggregate}
showToast={showToast}
fmtRel={fmtRel}
/>
{/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
......@@ -471,12 +472,14 @@ function TopBar({
patientId,
onRefreshAggregate,
showToast,
fmtRel,
}: {
plan: typeof mockPlan;
reason: typeof mockPlan.reasons[0];
patientId?: string;
onRefreshAggregate?: () => void | Promise<void>;
showToast?: (kind: string, title: string, msg: string) => void;
fmtRel?: (d: Date) => string;
}) {
const user = useAuthStore((s) => s.user);
const [refreshing, setRefreshing] = useState(false);
......@@ -537,6 +540,15 @@ function TopBar({
</PriorityHover>
</div>
<div className="flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600">
{/* 数据新鲜度 — 该 plan 版本重算时间;宽屏才显,跟刷新按钮成对 */}
{plan.updatedAt && fmtRel && (
<span
className="hidden md:inline text-[11px] text-slate-400"
title={`本召回计划最近重算于 ${plan.updatedAt.toLocaleString('zh-CN')}`}
>
更新于 {fmtRel(plan.updatedAt)}
</span>
)}
{/* 刷新 — 窄屏只显图标节省空间 */}
{patientId && (
<button
......
......@@ -5,6 +5,9 @@
* 前端 (app)/plans/[planId] 用本类型 + adaptData 转换给 <PlanDetailApp> 渲染。
*/
export type PlanDetailData = {
/// 该患者「当前活跃」plan id。请求的 plan 已 superseded 时,后端解析为最新版本 id;
/// 否则 = 请求的 plan id。前端据此 router.replace,避免老 plan URL 停在陈旧快照。
currentPlanId: string;
patient: {
id: string;
externalId: string;
......@@ -48,6 +51,8 @@ export type PlanDetailData = {
assigneeUserId: string | null;
assignedAt: string | null;
recycleAt: string | null;
/// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度
updatedAt: string;
reasons: Array<{
id: string;
scenario: string;
......
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