Commit 23e18d92 by luoqi

feat(plans): 左栏患者列表加「回访结果」角标(成功/不成功/保持)

有提交过执行记录的患者,在列表行左上角按最近一次 outcome 的分组打标签,
分组/文案/配色复用 @pac/types EXECUTION_OUTCOME_META 单一真理源。
列表接口批量带出每 plan 最近 outcome;详情页提交回访后左栏角标即时反映(不重拉)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d98dcbd4
Pipeline #3344 failed in 0 seconds
...@@ -107,6 +107,21 @@ export class PlanService { ...@@ -107,6 +107,21 @@ export class PlanService {
: []; : [];
const patientById = new Map(patients.map((p) => [p.id, p])); const patientById = new Map(patients.map((p) => [p.id, p]));
// 每个 plan 最近一次执行结果(左栏「成功/不成功/保持」角标用)。
// 本页 plan 数少(≤pageSize),执行记录也少;一次拉齐后 JS 取每 plan 最新一条。
const planIds = rows.map((r) => r.id);
const execs = planIds.length > 0
? await this.prisma.planExecution.findMany({
where: { planId: { in: planIds } },
select: { planId: true, outcome: true },
orderBy: { createdAt: 'desc' },
})
: [];
const lastOutcomeByPlan = new Map<string, string>();
for (const e of execs) {
if (!lastOutcomeByPlan.has(e.planId)) lastOutcomeByPlan.set(e.planId, e.outcome);
}
return { return {
items: rows.map((p) => { items: rows.map((p) => {
const base = serializePlan(p); const base = serializePlan(p);
...@@ -114,6 +129,7 @@ export class PlanService { ...@@ -114,6 +129,7 @@ export class PlanService {
return { return {
...base, ...base,
scenarioLabel: planScenarioLabel(base.scenario), scenarioLabel: planScenarioLabel(base.scenario),
lastOutcome: (lastOutcomeByPlan.get(p.id) ?? null) as ListPlansResponse['items'][number]['lastOutcome'],
patient: { patient: {
externalId: patient?.externalId ?? '', externalId: patient?.externalId ?? '',
/// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底 /// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底
......
...@@ -275,8 +275,8 @@ export function PlanDetailApp({ ...@@ -275,8 +275,8 @@ export function PlanDetailApp({
status: result.planStatus, status: result.planStatus,
contactAttempts: result.contactAttempts, contactAttempts: result.contactAttempts,
}); });
// 通知左栏选患者列同步该行状态(工作台跨栏一致) // 通知左栏选患者列同步该行状态 + 本次 outcome(工作台跨栏一致,左栏角标即时反映)
usePlanSyncStore.getState().notify(plan.id, result.planStatus); usePlanSyncStore.getState().notify(plan.id, result.planStatus, formData.outcome);
// 触达熔断是用户应知的"非预期"事件 → 保留 toast;普通归档不弹(setPlanOverride 已就地更新状态徽标即反馈) // 触达熔断是用户应知的"非预期"事件 → 保留 toast;普通归档不弹(setPlanOverride 已就地更新状态徽标即反馈)
if (result.breakerTripped) { if (result.breakerTripped) {
......
...@@ -5,7 +5,15 @@ import { useRouter } from 'next/navigation'; ...@@ -5,7 +5,15 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { plansApi } from './plans-api'; import { plansApi } from './plans-api';
import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react';
import { PERSONA_TAG_FILTER_DIMS, Permission, type PlanListItem } from '@pac/types'; import {
EXECUTION_OUTCOME_GROUP_META,
EXECUTION_OUTCOME_META,
PERSONA_TAG_FILTER_DIMS,
Permission,
type ExecutionOutcome,
type ExecutionOutcomeGroup,
type PlanListItem,
} from '@pac/types';
import { cn, formatGender } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { useAuthStore, visibleClinics } from '@/stores/auth-store'; import { useAuthStore, visibleClinics } from '@/stores/auth-store';
...@@ -75,7 +83,10 @@ export function PatientPickerRail({ ...@@ -75,7 +83,10 @@ export function PatientPickerRail({
const sync = usePlanSyncStore(); const sync = usePlanSyncStore();
useEffect(() => { useEffect(() => {
if (!sync.planId) return; if (!sync.planId) return;
if (sync.status) patchItem(sync.planId, { status: sync.status as PlanListItem['status'] }); const patch: Partial<PlanListItem> = {};
if (sync.status) patch.status = sync.status as PlanListItem['status'];
if (sync.outcome) patch.lastOutcome = sync.outcome as ExecutionOutcome;
if (Object.keys(patch).length) patchItem(sync.planId, patch);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sync.seq]); }, [sync.seq]);
...@@ -386,6 +397,31 @@ function StatusPill({ status }: { status: string }) { ...@@ -386,6 +397,31 @@ function StatusPill({ status }: { status: string }) {
return <span className={cn('inline-block flex-none rounded px-1.5 py-0.5 text-[10px] font-medium', m.tone)}>{m.label}</span>; return <span className={cn('inline-block flex-none rounded px-1.5 py-0.5 text-[10px] font-medium', m.tone)}>{m.label}</span>;
} }
// ── 回访结果角标(最近一次执行的分组:成功 / 不成功 / 保持)────────
// 分组 / 文案 / 色板全部取自 @pac/types 单一真理源(EXECUTION_OUTCOME_META)。
const OUTCOME_GROUP_TONE: Record<ExecutionOutcomeGroup, string> = {
close: 'bg-emerald-100 text-emerald-700 ring-emerald-200', // 成功
give_up: 'bg-rose-100 text-rose-700 ring-rose-200', // 不成功
keep: 'bg-amber-100 text-amber-700 ring-amber-200', // 保持
};
function OutcomeTag({ outcome }: { outcome: string }) {
const meta = EXECUTION_OUTCOME_META[outcome as ExecutionOutcome];
if (!meta) return null;
const group = EXECUTION_OUTCOME_GROUP_META[meta.group];
return (
<span
title={`回访结果:${group.labelZh} · ${meta.labelZh}`}
className={cn(
'flex-none rounded px-1 text-[9.5px] font-medium leading-4 ring-1 ring-inset',
OUTCOME_GROUP_TONE[meta.group],
)}
>
{group.labelZh}
</span>
);
}
// ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)──────── // ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)────────
function PriorityBar({ score, raw }: { score: number; raw?: number }) { function PriorityBar({ score, raw }: { score: number; raw?: number }) {
const pct = Math.max(0, Math.min(1, score / 100)); const pct = Math.max(0, Math.min(1, score / 100));
...@@ -433,6 +469,7 @@ function PatientRow({ ...@@ -433,6 +469,7 @@ function PatientRow({
)} )}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{p.lastOutcome && <OutcomeTag outcome={p.lastOutcome} />}
<span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}> <span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}>
{p.patient.name ?? p.patient.nameMasked ?? '—'} {p.patient.name ?? p.patient.nameMasked ?? '—'}
</span> </span>
......
...@@ -11,7 +11,9 @@ interface PlanSyncState { ...@@ -11,7 +11,9 @@ interface PlanSyncState {
planId: string | null; planId: string | null;
/** 新状态(submitExecution 回的 planStatus 等);null = 只提示"该行变了"(消费方自行 refresh) */ /** 新状态(submitExecution 回的 planStatus 等);null = 只提示"该行变了"(消费方自行 refresh) */
status: string | null; status: string | null;
notify: (planId: string, status?: string) => void; /** 本次提交的执行 outcome(左栏「成功/不成功/保持」角标即时反映);null = 未变 */
outcome: string | null;
notify: (planId: string, status?: string, outcome?: string) => void;
/** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */ /** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */
current: { planId: string; patientName: string } | null; current: { planId: string; patientName: string } | null;
setCurrent: (current: { planId: string; patientName: string } | null) => void; setCurrent: (current: { planId: string; patientName: string } | null) => void;
...@@ -21,8 +23,9 @@ export const usePlanSyncStore = create<PlanSyncState>((set) => ({ ...@@ -21,8 +23,9 @@ export const usePlanSyncStore = create<PlanSyncState>((set) => ({
seq: 0, seq: 0,
planId: null, planId: null,
status: null, status: null,
notify: (planId, status) => outcome: null,
set((s) => ({ seq: s.seq + 1, planId, status: status ?? null })), notify: (planId, status, outcome) =>
set((s) => ({ seq: s.seq + 1, planId, status: status ?? null, outcome: outcome ?? null })),
current: null, current: null,
setCurrent: (current) => set({ current }), setCurrent: (current) => set({ current }),
})); }));
...@@ -187,6 +187,9 @@ export const PlanListItemSchema = FollowupPlanSchema.extend({ ...@@ -187,6 +187,9 @@ export const PlanListItemSchema = FollowupPlanSchema.extend({
patient: PlanPatientBriefSchema, patient: PlanPatientBriefSchema,
scenarioLabel: z.string(), scenarioLabel: z.string(),
reasons: z.array(PlanReasonBriefSchema), reasons: z.array(PlanReasonBriefSchema),
/// 该 plan 最近一次执行(回访提交)的 outcome — 列表左栏「成功/不成功/保持」角标用。
/// null = 尚无执行记录。分组/文案/色板查 EXECUTION_OUTCOME_META。
lastOutcome: ExecutionOutcomeSchema.nullable().optional(),
}); });
export type PlanListItem = z.infer<typeof PlanListItemSchema>; export type PlanListItem = z.infer<typeof PlanListItemSchema>;
......
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