Commit 524efac7 by luoqi

召回闭环 + 通话结果重构 + 召回反馈 + 工作台修复

召回闭环(snoozedUntil 列):
- execution 按 outcome 写 snoozedUntil(成功60/拒绝90/外院·无效永久/熔断30/约定回访到回访日)
- upsertPlan 信号级抑制:终态+冷静期内同 (scenario,subKey) 不复活;患者新长的别的诊断照常召回
- pool 视图过滤未到期 snooze;去掉 considering 的 likelihood 加权
- 回收 cron(每10min):超时 assigned 自动回池,跳过约定回访中的(snooze 未到不收)

详情页:
- 患者级"召回历史"卡(跨版本最近8条)+ "暂缓跟进"角标
- 终态(已完成/已放弃)提交按钮置灰 + 提示

工作台修复:
- 结案保留 assigneeUserId(我的已完成/转化率不再恒为0)
- "全部我的"含已完成;assigned 状态文案改"进行中"

通话结果重构:13→3 大类(结案/保持/放弃),group状态机列表tab 一一对应

召回反馈:plan_executions.recall_feedback,5 tag 选填收集一线对召回准确性的反馈(喂算法迭代)

日期组件:shadcn Date Picker(Calendar+Popover,captionLayout dropdown)+ 原生时间输入

迁移:20260601032022_add_plan_snoozed_until / 20260601072323_add_execution_recall_feedback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b8fbd7ad
-- AlterTable
ALTER TABLE "followup_plans" ADD COLUMN "snoozed_until" TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "plan_executions" ADD COLUMN "recall_feedback" TEXT[];
......@@ -755,6 +755,12 @@ model FollowupPlan {
/// 不是聚合, assignment 时确定的固定值;cron 每分钟扫 WHERE status='assigned' AND recycle_at < NOW()
recycleAt DateTime? @map("recycle_at") @db.Timestamptz(3)
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写时按 outcome 计算, EXECUTION_OUTCOME_META.suppressDays):
/// - 终态(completed/abandoned)+ now<snoozedUntil plan 引擎 upsert **不重新生成**(防熔断/拒绝被重算复活)
/// - keep 类带 scheduledNextAt / declined_recent 写入后 active plan 在到期前**不进召回池**(到点自动浮现)
/// null = 无抑制(立即可召回 / 立即在池)。到期(now>=snoozedUntil)自愈。
snoozedUntil DateTime? @map("snoozed_until") @db.Timestamptz(3)
/// 实际指派给谁(宿主侧 user id, token );展示用 name 通过 token 字典查
assigneeUserId String? @map("assignee_user_id")
/// 指派发生时间
......@@ -1052,6 +1058,11 @@ model PlanExecution {
/// outcome=scheduled_next ,下次回访时间
scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3)
/// 召回反馈(选填·多选) 一线对"这条召回准不准"的反馈,**正交于 outcome**
/// 值见 @pac/types RECALL_FEEDBACK_OPTIONS(info_wrong/already_handled/duplicate_contact/bad_timing/not_worth)
/// 用途:聚合定位召回算法系统性问题(产品/算法迭代输入),不参与状态机 / 抑制。
recallFeedback String[] @map("recall_feedback")
/// 记录提交时间 通话结束时间。
/// 通话开始时间 / 时长由设备通话系统记录,不在 PAC 范围;客服手填不准故 PAC 不立柱
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
......
......@@ -58,6 +58,7 @@ async function bootstrap() {
logger.log(` plansSuperseded: ${r.plansSuperseded}`);
logger.log(` plansUnchanged: ${r.plansUnchanged}`);
logger.log(` plansSkippedAssigned: ${r.plansSkippedAssigned}`);
logger.log(` plansSuppressed: ${r.plansSuppressed}`);
logger.log(` duration: ${r.durationMs}ms`);
logger.log(`──────────────────────────────────────────────────────`);
}
......
......@@ -217,7 +217,7 @@ async function bootstrap() {
});
totalPlansCreated += r.plansCreated;
totalHits += r.patientsHit;
logger.log(` tenant=${t.tenantId}: patientsHit=${r.patientsHit} plansCreated=${r.plansCreated} skipped=${r.plansSkippedAssigned}`);
logger.log(` tenant=${t.tenantId}: patientsHit=${r.patientsHit} plansCreated=${r.plansCreated} skipped=${r.plansSkippedAssigned} suppressed=${r.plansSuppressed}`);
}
logger.log(` Total: patientsHit=${totalHits} plansCreated=${totalPlansCreated}`);
logger.log('─────────────────────────────────────────');
......
......@@ -130,6 +130,10 @@ export class PlanAggregateService {
c.target = hit;
}
// 召回历史(患者级,跨所有 plan 版本)—— 再次召回是新 plan id,旧执行挂在旧 plan 上,
// 必须按 patient 取才看得到"上次为什么被压住 / 上次结果"。也顺带补上之前缺失的通话历史。
const recallHistory = await this.loadRecallHistory(scope, patient.id);
return {
patient: serializePatient(patient),
profile: serializeProfile(patient, facts, facts.filter((f) => f.type === 'encounter_record')),
......@@ -138,10 +142,41 @@ export class PlanAggregateService {
chains,
facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null,
recallHistory,
};
}
/**
* 患者级召回历史 —— 最近 N 次执行结果(跨该患者所有 plan 版本)。
* 用途:plan 再次被召回时,客服能看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
*/
private async loadRecallHistory(scope: TenantScopeContext, patientId: string) {
const rows = await this.prisma.planExecution.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, plan: { patientId } },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
outcome: true,
channel: true,
notes: true,
scheduledNextAt: true,
createdAt: true,
plan: { select: { version: true } },
},
});
return rows.map((e) => ({
id: e.id,
outcome: e.outcome,
channel: e.channel,
notes: e.notes,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
planVersion: e.plan?.version ?? null,
}));
}
/**
* W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
* pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
*/
......@@ -291,6 +326,7 @@ function serializePlan(plan: {
assigneeUserId: string | null;
assignedAt: Date | null;
recycleAt: Date | null;
snoozedUntil: Date | null;
updatedAt: Date;
reasons: Array<{
id: string;
......@@ -322,6 +358,8 @@ function serializePlan(plan: {
assigneeUserId: plan.assigneeUserId,
assignedAt: plan.assignedAt?.toISOString() ?? null,
recycleAt: plan.recycleAt?.toISOString() ?? null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
reasons: plan.reasons.map((r) => ({
id: r.id,
scenario: r.scenario,
......
......@@ -122,6 +122,7 @@ export class PlanEngineService {
let plansSuperseded = 0;
let plansUnchanged = 0;
let plansSkippedAssigned = 0;
let plansSuppressed = 0;
// 2. 逐 patient 写 plan(每个 patient 一条 PlanGenerationLog,跟 schema 设计一致)
for (const [patientId, hits] of hitsByPatient.entries()) {
......@@ -141,6 +142,7 @@ export class PlanEngineService {
else if (result === 'superseded') plansSuperseded++;
else if (result === 'unchanged') plansUnchanged++;
else if (result === 'skipped_assigned') plansSkippedAssigned++;
else if (result === 'suppressed') plansSuppressed++;
await this.prisma.planGenerationLog.update({
where: { id: log.id },
......@@ -172,6 +174,7 @@ export class PlanEngineService {
plansSuperseded,
plansUnchanged,
plansSkippedAssigned,
plansSuppressed,
durationMs: Date.now() - startedAt.getTime(),
};
}
......@@ -181,7 +184,7 @@ export class PlanEngineService {
scope: ScenarioScope;
patientId: string;
hits: ScenarioHitWithKey[];
}): Promise<'created' | 'superseded' | 'unchanged' | 'skipped_assigned'> {
}): Promise<'created' | 'superseded' | 'unchanged' | 'skipped_assigned' | 'suppressed'> {
const { scope, patientId, hits } = input;
const latest = await this.prisma.followupPlan.findFirst({
......@@ -195,8 +198,23 @@ export class PlanEngineService {
return 'skipped_assigned';
}
const newPriorityScore = Math.max(...hits.map((h) => h.priorityScore));
const newReasonSet = new Set(hits.map((h) => h.scenarioKey + ':' + (h.subKey ?? '')));
// ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)。
// 只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
// 做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
const snoozedKeys = await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits =
snoozedKeys.size === 0
? hits
: hits.filter((h) => !snoozedKeys.has(`${h.scenarioKey}|${h.subKey ?? ''}`));
if (usableHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批)→ 不生成新 plan
return 'suppressed';
}
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
// 比对 active plan 的 reason 是否变化
// ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
......@@ -207,7 +225,7 @@ export class PlanEngineService {
if (isActive) {
const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`;
const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey)));
const newSubScenarios = new Set(hits.map((h) => key(h.scenarioKey, h.subKey)));
const newSubScenarios = new Set(usableHits.map((h) => key(h.scenarioKey, h.subKey)));
const sameSubScenarios =
oldSubScenarios.size === newSubScenarios.size &&
[...oldSubScenarios].every((k) => newSubScenarios.has(k));
......@@ -226,7 +244,7 @@ export class PlanEngineService {
// 需要新版本:supersede 旧 + 创建新
const nextVersion = (latest?.version ?? 0) + 1;
// head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
const head = [...hits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// 拉对应 patient 的最新 active persona(可空)
const persona = await this.prisma.persona.findFirst({
......@@ -262,7 +280,7 @@ export class PlanEngineService {
// 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。
// UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。
createMany: {
data: hits.map((h) => ({
data: usableHits.map((h) => ({
scenario: h.scenarioKey,
subKey: h.subKey ?? null,
priorityScore: h.priorityScore,
......@@ -292,6 +310,39 @@ export class PlanEngineService {
return latest ? 'superseded' : 'created';
}
/**
* 信号级抑制集 —— 该患者所有"终态(completed/abandoned)+ 冷静期未到期(snoozedUntil>now)"plan
* 覆盖的召回理由 (scenario, subKey) 并集。
*
* 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
* 但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
* 多次结案累积正确:取全部未到期终态 plan 的并集,而非只看最新一条。
* 返回 key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
*/
private async fetchSnoozedSignalKeys(
scope: ScenarioScope,
patientId: string,
now: Date,
): Promise<Set<string>> {
const terminalPlans = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
},
select: { reasons: { select: { scenario: true, subKey: true } } },
});
const keys = new Set<string>();
for (const plan of terminalPlans) {
for (const r of plan.reasons) {
keys.add(`${r.scenario}|${r.subKey ?? ''}`);
}
}
return keys;
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
private lifecycleFor(scenario: string, subKey?: string): string {
// 复查类长效(种植年度 / 牙周维护),其余短效
......@@ -309,5 +360,6 @@ export interface EngineRunResult {
plansSuperseded: number;
plansUnchanged: number;
plansSkippedAssigned: number;
plansSuppressed: number;
durationMs: number;
}
......@@ -149,8 +149,11 @@ export function computeLikelihoodBonus(
// recall_risk:0=none / 1=low / 2=medium / 3=high — 越高触达可能性越低
const risk = riskScore ?? 1;
const riskBonus = Math.max(0, Math.round((3 - risk) * 2)); // 0~6
// 仅"真实正向进展"加权:成功转化 / 改约(都已敲定新时间)。
// 注:'considering'(考虑中)已移除 — 软意向不该把患者顶到列表最前天天打;
// 且 considering 现在走 snoozedUntil 冷静期,不应再额外加权(否则与抑制窗自相矛盾)。
const recentSuccessBonus = recentExecutions.some((e) =>
['success_appointed', 'rescheduled', 'considering'].includes(e.outcome),
['success_appointed', 'rescheduled'].includes(e.outcome),
)
? 4
: 0;
......
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { resolveSnoozedUntil } from './recall-suppression';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/**
......@@ -22,6 +23,10 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
const MAX_CONTACT_ATTEMPTS_DEFAULT = 4; // 暂常量,后续接 host/scenario 配置
/// 约定回访后,给经办人在回访日之后再留多久才允许自动回收(宽限期)。
/// 让"约定回访"的 plan 在回访日当天仍归经办人,不会被 24h 超时提前回收 / 回访日一到就被别人捞走。
const RECYCLE_GRACE_AFTER_SNOOZE_MS = 24 * 3600_000;
/**
* outcome → plan.status 映射(spec § "按 outcome 驱动 Plan.status"):
* completed = 闭环成功 / 不再需要召回(转化 / 外院 / 明确拒绝 / 无效)
......@@ -48,6 +53,8 @@ export interface SubmitExecutionInput {
abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string;
/// 召回反馈 tag(选填·多选)— 对召回算法准确性的反馈,不参与状态机
recallFeedback?: string[];
invalidReason?: string;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId?: string;
......@@ -127,6 +134,17 @@ export class ExecutionService {
}
}
// ─── 3.5 计算 snoozedUntil(召回闭环核心,见 resolveSnoozedUntil)───
// - 终态 + snoozedUntil → plan 引擎 upsert 时不重新生成(防熔断/拒绝被重算复活)
// - keep 类 + snoozedUntil → active plan 到期前不进召回池(到点浮现)
const now = new Date();
const snoozedUntil = resolveSnoozedUntil({
outcome: input.outcome,
breakerTripped,
scheduledNextAt: input.scheduledNextAt,
now,
});
// ─── 4. 事务:写 execution + 更新 plan ───
const result = await this.prisma.$transaction(async (tx) => {
const execution = await tx.planExecution.create({
......@@ -143,6 +161,7 @@ export class ExecutionService {
abandonReasons: input.abandonReasons ?? [],
abandonOther: input.abandonOther ?? null,
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
recallFeedback: input.recallFeedback ?? [],
},
select: { id: true },
});
......@@ -152,9 +171,18 @@ export class ExecutionService {
data: {
status: newStatus,
contactAttempts: nextContactAttempts,
// 终态额外清理 assignee(让 plan 离开"我的工单"列表)
// 召回冷静期 / 终态抑制窗(null 时显式清空 → 重新触达后老 snooze 不残留)
snoozedUntil,
// 终态:只清自动回收 deadline(不再相关)。
// ⭐ 保留 assigneeUserId / assignedAt 作业绩归属 —— 让"我的已完成" tab 看得到、
// "我完成"KPI + 转化率算得对。离开"进行中"靠 status 变化(列表按 status 过滤),
// 不靠清 assignee(清了会让 mineCompleted / 转化率 永远为 0)。
// keep 类 + 有 snooze(约定回访 6/10 等):把 recycleAt 顺延到回访日 + 宽限,
// 否则分配 24h 后 recycleAt 一过,回收 cron 会把约了回访的 plan 从经办人手里收走。
...(newStatus === 'completed' || newStatus === 'abandoned'
? { assigneeUserId: null, assignedAt: null, recycleAt: null }
? { recycleAt: null }
: snoozedUntil
? { recycleAt: new Date(snoozedUntil.getTime() + RECYCLE_GRACE_AFTER_SNOOZE_MS) }
: {}),
},
});
......@@ -197,6 +225,7 @@ export class ExecutionService {
notes: e.notes,
invalidReason: e.invalidReason,
abandonReasons: e.abandonReasons,
recallFeedback: e.recallFeedback,
abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
......
......@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller';
import { PlanService } from './plan.service';
import { ExecutionService } from './execution.service';
import { RecycleSchedulerService } from './recycle-scheduler.service';
import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
......@@ -15,6 +16,7 @@ import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-
providers: [
PlanService,
ExecutionService,
RecycleSchedulerService,
PlanEngineService,
ChainComposerService,
TreatmentInitiationRecallScenario,
......
......@@ -66,7 +66,15 @@ export class PlanService {
if (view === 'pool') {
where.status = 'active';
where.assigneeUserId = null;
// 回访调度:snooze 未到期的不进召回池(约定回访 / 考虑中 / 近期不考虑 等到点自动浮现)。
// 仅作用于 pool 视图 — 'mine' 仍展示我 snooze 的工单(客服看得到自己的回访安排)。
where.AND = [
{ OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: new Date() } }] },
];
} else if (view === 'mine') {
// "全部我的" = 我名下所有状态(active/assigned/completed/abandoned);
// 具体状态由前端「进行中 / 已完成 / 已放弃」子 tab 通过 query.status 显式过滤。
// (结案现在保留 assignee,所以"已完成"能查到。)
where.assigneeUserId = scope.userId;
} else if (view === 'all') {
if (!permissions.includes(Permission.PLAN_VIEW_ALL)) {
......@@ -417,6 +425,7 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
evidence,
status: p.status as import('@pac/types').PlanStatus,
recycleAt: p.recycleAt?.toISOString() ?? null,
snoozedUntil: p.snoozedUntil?.toISOString() ?? null,
assigneeUserId: p.assigneeUserId,
assignedAt: p.assignedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
......@@ -519,6 +528,7 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu
abandonReasons: e.abandonReasons,
abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
recallFeedback: e.recallFeedback,
createdAt: e.createdAt.toISOString(),
};
}
......
import {
EXECUTION_OUTCOME_META,
ExecutionOutcome,
BREAKER_SUPPRESS_DAYS,
} from '@pac/types';
const DAY_MS = 86_400_000;
/**
* 计算 execution 回写后的 snoozedUntil(召回冷静期 / 终态抑制窗)—— 纯函数,便于单测。
*
* 优先级(高 → 低):
* 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d),
* 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。
* 2. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。
* 3. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
* 4. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
*
* @returns snoozedUntil Date,或 null(不抑制)
*/
export function resolveSnoozedUntil(params: {
outcome: string;
breakerTripped: boolean;
scheduledNextAt?: string | null;
now: Date;
}): Date | null {
const { outcome, breakerTripped, scheduledNextAt, now } = params;
if (breakerTripped) {
return new Date(now.getTime() + BREAKER_SUPPRESS_DAYS * DAY_MS);
}
const meta = EXECUTION_OUTCOME_META[outcome as ExecutionOutcome];
if (meta?.suppressDays != null) {
return new Date(now.getTime() + meta.suppressDays * DAY_MS);
}
if (scheduledNextAt) {
return new Date(scheduledNextAt);
}
return null;
}
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
/**
* RecycleSchedulerService — 召回工单超时自动回收(GAP3 闭环)
*
* 背景:assign 时锁定 recycleAt = assignedAt + RECYCLE_TIMEOUT_HOURS(默认 24h)。
* 旧实现只写了 deadline 却**没有任何 cron 执行回收** —— 客服领了工单去休假 / 漏跟,
* plan 永远停在 assigned,plan 引擎 upsert 时 `skipped_assigned` 永久跳过,
* 这个患者既不会被别人捡走、也不会更新。本服务补上自动回收。
*
* 行为:每 10 分钟扫一遍,把"已分配 + 已过 recycleAt"的 plan 退回召回池:
* status: assigned → active;清空 assignee / assignedAt / recycleAt。
* 用 updateMany 一条 SQL 批量处理(无需逐行),幂等、并发安全。
*
* 注:手动回收(POST /plans/{id}/recycle)仍保留,本 cron 只兜底超时未结案的。
*/
@Injectable()
export class RecycleSchedulerService {
private readonly logger = new Logger(RecycleSchedulerService.name);
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-recycle-stale' })
async runRecycle(): Promise<void> {
const now = new Date();
const r = await this.prisma.followupPlan.updateMany({
where: {
status: 'assigned',
recycleAt: { not: null, lt: now },
// ⭐ 跳过仍在冷静期/约定回访中的 plan(snoozedUntil 在未来)。
// 场景:客服约了 6/10 回访,plan snooze 到 6/10;在那之前**不能**因 24h 超时被回收,
// 否则客服丢了已承诺的回访关系、6/10 一到还会被别人从池里捞走。
// 回访日过后(snoozedUntil<=now)若仍未处理,才允许回收。
OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
},
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
},
});
if (r.count > 0) {
this.logger.log(`auto-recycle: ${r.count} 个超时未结案工单退回召回池`);
}
}
}
import {
EXECUTION_OUTCOME_META,
SUPPRESS_PERMANENT_DAYS,
BREAKER_SUPPRESS_DAYS,
} from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import { computeLikelihoodBonus } from '../src/modules/plan/engine/priority-scorer';
/**
* 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
*
* 修复的核心 bug(GAP1):终态 plan(completed/abandoned)在重算时被当成"没有活动 plan"
* 直接新建一版 active → "明确拒绝 / 4 次未接通熔断"第二天就复活、患者重回呼叫池。
* 现在 execution 回写按 outcome 算 snoozedUntil,plan 引擎 upsert 在抑制期内返回 suppressed。
*/
describe('召回抑制窗 snoozedUntil 计算', () => {
const NOW = new Date('2026-06-01T00:00:00Z');
const daysFromNow = (d: Date | null) =>
d == null ? null : Math.round((d.getTime() - NOW.getTime()) / 86_400_000);
test('明确拒绝 refused → 90d 冷静期', () => {
const r = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(90);
});
test('近期不考虑 declined_recent → 90d(虽 keep 类,仍冷静期内不进池)', () => {
const r = resolveSnoozedUntil({ outcome: 'declined_recent', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(90);
});
test('成功转化 success_appointed → 60d(覆盖预约→治疗→摄入延迟,避免重复召)', () => {
const r = resolveSnoozedUntil({ outcome: 'success_appointed', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(60);
});
test('客服放弃 abandoned → 60d', () => {
const r = resolveSnoozedUntil({ outcome: 'abandoned', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(60);
});
test('外院治疗 external_treatment → 永久(真闭环)', () => {
const r = resolveSnoozedUntil({ outcome: 'external_treatment', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(SUPPRESS_PERMANENT_DAYS);
});
test('标记无效 marked_invalid → 永久', () => {
const r = resolveSnoozedUntil({ outcome: 'marked_invalid', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(SUPPRESS_PERMANENT_DAYS);
});
test('熔断优先于 outcome 自身策略 → 固定 30d(联系不上,非拒绝,换天再试)', () => {
// outcome=no_answer 本身 suppressDays=null,但熔断触发 → 30d
const r = resolveSnoozedUntil({ outcome: 'no_answer', breakerTripped: true, now: NOW });
expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
});
test('熔断即便发生在终态 outcome 上,也用 30d 而非 outcome 的窗', () => {
// 防御:breaker 优先级最高
const r = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: true, now: NOW });
expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
});
test('keep 类带 scheduledNextAt(约定回访)→ snooze 到该时间点', () => {
const next = '2026-06-15T02:00:00Z';
const r = resolveSnoozedUntil({
outcome: 'scheduled_next',
breakerTripped: false,
scheduledNextAt: next,
now: NOW,
});
expect(r?.toISOString()).toBe(new Date(next).toISOString());
});
test('考虑中 considering 无 scheduledNextAt → 不 snooze(null,维持现状)', () => {
const r = resolveSnoozedUntil({ outcome: 'considering', breakerTripped: false, now: NOW });
expect(r).toBeNull();
});
test('未接通 no_answer 无 scheduledNextAt → 不 snooze', () => {
const r = resolveSnoozedUntil({ outcome: 'no_answer', breakerTripped: false, now: NOW });
expect(r).toBeNull();
});
});
describe('优先级:considering 不再加权(GAP2)', () => {
test('考虑中不应把患者顶到列表最前(冷静期内 + 不加权)', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'considering' }])).toBe(0);
});
test('真实正向进展(成功转化 / 改约)仍 +4', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'success_appointed' }])).toBe(4);
expect(computeLikelihoodBonus(3, [{ outcome: 'rescheduled' }])).toBe(4);
});
test('无近期正向执行 → 仅 riskBonus', () => {
expect(computeLikelihoodBonus(3, [{ outcome: 'no_answer' }])).toBe(0); // risk=3 → riskBonus 0
expect(computeLikelihoodBonus(0, [])).toBe(6); // risk=0 → (3-0)*2=6
});
});
describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () => {
test('每个 outcome 都声明了 suppressDays(number 或 null),无遗漏', () => {
for (const [k, v] of Object.entries(EXECUTION_OUTCOME_META)) {
expect(v).toHaveProperty('suppressDays');
expect(v.suppressDays === null || typeof v.suppressDays === 'number').toBe(true);
if (typeof v.suppressDays === 'number') {
expect(v.suppressDays).toBeGreaterThan(0);
}
void k;
}
});
});
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
......@@ -17,14 +17,17 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.14.0",
"next": "^16.2.4",
"react": "^19.2.5",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
......
......@@ -141,6 +141,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,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : 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,
......@@ -180,6 +181,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
sections: real.script.sections,
} as typeof mockScript)
: mockScript,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [],
// outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import
fmtRel,
};
......
......@@ -16,6 +16,7 @@ export interface SubmitExecutionBody {
abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string;
recallFeedback?: string[];
}
/**
......
......@@ -289,6 +289,8 @@ 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,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null,
updatedAt: NOW as Date | null,
recommendedAt: NOW,
recommendedRole: UserRole.STAFF as UserRole,
......
'use client';
import { useState } from 'react';
import { EXECUTION_OUTCOME_META, type ExecutionOutcome } from '@pac/types';
import {
EXECUTION_OUTCOME_META,
EXECUTION_OUTCOME_GROUP_META,
RECALL_FEEDBACK_OPTIONS,
type ExecutionOutcome,
type ExecutionOutcomeGroup,
} from '@pac/types';
import { cn } from '@/lib/utils';
import { tone } from './shared';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
/// 本地日期 ⇄ 'yyyy-MM-dd' 字符串(避免 new Date(ISO) 的时区偏移)
const toYMD = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const fromYMD = (s: string) => {
const [y, m, d] = s.split('-').map(Number);
return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
};
/// outcome 选项从 @pac/types EXECUTION_OUTCOME_META 派生(单一真理源,跟后端共享)
/// 改 enum / label / 状态机映射只在 packages/types 一处改,前后端同步生效
const OUTCOME_OPTIONS = (
Object.entries(EXECUTION_OUTCOME_META) as Array<[ExecutionOutcome, (typeof EXECUTION_OUTCOME_META)[ExecutionOutcome]]>
).map(([value, meta]) => ({
value,
label: meta.labelZh,
tone: meta.tone,
drives: meta.drivesStatus,
}));
/// 改 enum / label / group / 状态机映射只在 packages/types 一处改,前后端同步生效
/// 2 级分组:按 group 归类(成功 / 约定回访 / 未接通 / 放弃 / 其他),hiddenInForm 的历史值不展示
const OUTCOME_GROUPS = (
Object.keys(EXECUTION_OUTCOME_GROUP_META) as ExecutionOutcomeGroup[]
)
.sort((a, b) => EXECUTION_OUTCOME_GROUP_META[a].order - EXECUTION_OUTCOME_GROUP_META[b].order)
.map((g) => ({
key: g,
label: EXECUTION_OUTCOME_GROUP_META[g].labelZh,
options: (
Object.entries(EXECUTION_OUTCOME_META) as Array<
[ExecutionOutcome, (typeof EXECUTION_OUTCOME_META)[ExecutionOutcome]]
>
)
.filter(([, meta]) => meta.group === g && !meta.hiddenInForm)
.map(([value, meta]) => ({ value, label: meta.labelZh, tone: meta.tone, drives: meta.drivesStatus })),
}))
.filter((grp) => grp.options.length > 0);
const CHANNELS = [
{
......@@ -55,13 +80,14 @@ export function OutcomeForm({
onCreateAppointment,
defaultChannel = 'phone',
}: {
plan: { contactAttempts: number };
plan: { contactAttempts: number; status?: string };
onSubmit: (data: {
channel: string;
outcome: string;
notes: string;
scheduledNextAt: string;
abandonReasons: string[];
recallFeedback: string[];
}) => void;
onCreateAppointment: () => void;
defaultChannel?: string;
......@@ -71,31 +97,44 @@ export function OutcomeForm({
const [notes, setNotes] = useState('');
const [scheduledNextAt, setScheduledNextAt] = useState('');
const [abandonReasons, setAbandonReasons] = useState<string[]>([]);
const [recallFeedback, setRecallFeedback] = useState<string[]>([]);
const [submitted, setSubmitted] = useState(false);
const cur = OUTCOME_OPTIONS.find((o) => o.value === outcome);
const curMeta = outcome ? EXECUTION_OUTCOME_META[outcome as ExecutionOutcome] : null;
const cur = curMeta ? { tone: curMeta.tone, drives: curMeta.drivesStatus } : null;
const needsScheduledNext =
!!outcome && ['scheduled_next', 'considering', 'rescheduled', 'pending_info'].includes(outcome);
const needsAbandonReasons = !!outcome && ['abandoned', 'refused', 'declined_recent'].includes(outcome);
!!outcome && ['scheduled_next', 'considering'].includes(outcome);
const needsAbandonReasons = !!outcome && outcome === 'refused';
const isSuccess = outcome === 'success_appointed';
const canSubmit = !!outcome && !!channel && !submitted;
// 终态(已结案/已放弃/已被替代)不可再写 execution(后端也会拒);提交按钮置灰 + 顶部提示
const isTerminal = ['completed', 'abandoned', 'superseded'].includes(plan.status ?? '');
const canSubmit = !!outcome && !!channel && !submitted && !isTerminal;
// 下次回访时间拆成 日期('yyyy-MM-dd')+ 时间('HH:mm')两部分,日历选日期、时间框选时间,合成回 scheduledNextAt
const schedDate = scheduledNextAt.split('T')[0] ?? '';
const schedTime = scheduledNextAt.split('T')[1] ?? '';
const handleSubmit = () => {
if (!canSubmit) return;
setSubmitted(true);
onSubmit({ channel, outcome: outcome!, notes, scheduledNextAt, abandonReasons });
onSubmit({ channel, outcome: outcome!, notes, scheduledNextAt, abandonReasons, recallFeedback });
setTimeout(() => {
setSubmitted(false);
setOutcome(null);
setNotes('');
setScheduledNextAt('');
setAbandonReasons([]);
setRecallFeedback([]);
setChannel(defaultChannel);
}, 2400);
};
return (
<div className="flex flex-col gap-2.5 h-full min-h-0">
{isTerminal && (
<div className="flex-none rounded bg-slate-100 border border-slate-200 px-2.5 py-1.5 text-[11px] text-slate-500 leading-snug">
本任务已{plan.status === 'abandoned' ? '放弃' : plan.status === 'superseded' ? '被新版本替代' : '结案'},不可再提交执行;如需重新跟进,等召回重新生成。
</div>
)}
<div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
触达方式 <span className="text-rose-500">*</span>
......@@ -138,8 +177,12 @@ export function OutcomeForm({
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
通话结果 <span className="text-rose-500">*</span>
</div>
<div className="space-y-2">
{OUTCOME_GROUPS.map((grp) => (
<div key={grp.key}>
<div className="text-[9.5px] font-semibold text-slate-400 mb-1 pl-0.5">{grp.label}</div>
<div className="grid grid-cols-2 gap-1">
{OUTCOME_OPTIONS.map((o) => {
{grp.options.map((o) => {
const T = tone(o.tone);
const selected = o.value === outcome;
return (
......@@ -159,6 +202,9 @@ export function OutcomeForm({
);
})}
</div>
</div>
))}
</div>
{cur && (
<div className={cn('mt-1.5 text-[10.5px] flex items-center gap-1.5', tone(cur.tone).text)}>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
......@@ -172,13 +218,25 @@ export function OutcomeForm({
{needsScheduledNext && (
<div className="flex-none rounded bg-amber-50 border border-amber-200 px-2.5 py-2">
<label className="block text-[10.5px] font-semibold text-amber-900 mb-1">下次回访时间</label>
<input
type="datetime-local"
value={scheduledNextAt}
onChange={(e) => setScheduledNextAt(e.target.value)}
className="w-full px-2 py-1 rounded border border-amber-200 bg-white text-[12px]"
<div className="flex gap-1.5">
<div className="flex-1 min-w-0">
<DatePicker
value={schedDate ? fromYMD(schedDate) : undefined}
// 选日期时,时间缺省给 10:00(没填时间也能合成有效 datetime)
onChange={(d) => setScheduledNextAt(d ? `${toYMD(d)}T${schedTime || '10:00'}` : '')}
min={new Date(new Date().setHours(0, 0, 0, 0))}
placeholder="选择回访日期"
/>
</div>
<Input
type="time"
value={schedTime}
disabled={!schedDate}
onChange={(e) => schedDate && setScheduledNextAt(`${schedDate}T${e.target.value}`)}
className="w-[96px] flex-none text-[12.5px] appearance-none bg-white [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
)}
{needsAbandonReasons && (
<div className="flex-none rounded bg-rose-50 border border-rose-200 px-2.5 py-2">
......@@ -206,6 +264,39 @@ export function OutcomeForm({
</div>
)}
{/* 召回反馈(选填·多选)— 对"这条召回准不准"的反馈,正交于通话结果,默认不选=没问题 */}
<div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
召回反馈
<span className="ml-2 font-normal normal-case text-[10px] text-slate-400 tracking-normal">选填,帮我们改进召回</span>
</div>
<div className="flex flex-wrap gap-1">
{RECALL_FEEDBACK_OPTIONS.map((f) => {
const on = recallFeedback.includes(f.value);
return (
<button
key={f.value}
type="button"
title={f.hint}
onClick={() =>
setRecallFeedback(
on ? recallFeedback.filter((x) => x !== f.value) : [...recallFeedback, f.value],
)
}
className={cn(
'px-2 py-1 rounded text-[11px] border transition-colors',
on
? 'bg-rose-50 text-rose-700 border-rose-300 font-medium ring-1 ring-rose-200'
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300 hover:bg-slate-50',
)}
>
{f.labelZh}
</button>
);
})}
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
<label className="flex-none text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1">
通话纪要
......
......@@ -17,7 +17,12 @@ import {
formatGender,
formatDaysReadable,
} from '@/lib/utils';
import { PersonaFeatureKey, treatmentCategoryNameZh } from '@pac/types';
import {
PersonaFeatureKey,
treatmentCategoryNameZh,
EXECUTION_OUTCOME_META,
type ExecutionOutcome,
} from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display';
......@@ -47,6 +52,17 @@ import { submitExecution, adaptAbandonReasons } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash';
/// 召回历史一条(患者级,跨所有 plan 版本)— 后端 plan-aggregate.recallHistory
export type RecallHistoryItem = {
id: string;
outcome: string;
channel: string;
notes: string | null;
scheduledNextAt: string | null;
createdAt: string;
planVersion: number | null;
};
export type PlanDetailAppData = {
patient: typeof mockPatient;
chains: typeof mockChains;
......@@ -56,6 +72,8 @@ export type PlanDetailAppData = {
facts?: AdaptedFact[];
summaries: typeof mockSummaries;
script: typeof mockScript;
/// 召回历史(患者级)— 可选,缺省空数组
recallHistory?: RecallHistoryItem[];
fmtRel: typeof mockFmtRel;
};
......@@ -84,6 +102,7 @@ export function PlanDetailApp({
}) {
const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
const facts = data.facts ?? [];
const recallHistory = data.recallHistory ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash
......@@ -170,6 +189,7 @@ export function PlanDetailApp({
notes: string;
scheduledNextAt: string;
abandonReasons: string[];
recallFeedback: string[];
}) => {
try {
const { abandonReasons, abandonOther } = adaptAbandonReasons(formData.abandonReasons);
......@@ -182,6 +202,7 @@ export function PlanDetailApp({
scheduledNextAt: formData.scheduledNextAt
? new Date(formData.scheduledNextAt).toISOString()
: undefined,
recallFeedback: formData.recallFeedback.length > 0 ? formData.recallFeedback : undefined,
});
// 本地立刻反映 plan 新态(不等下次拉数据)
......@@ -255,6 +276,9 @@ export function PlanDetailApp({
persona={persona}
facts={facts}
/>
{recallHistory.length > 0 && (
<RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
)}
<SidebarCard
title="治疗链"
meta={`${chains.length} 条`}
......@@ -1105,6 +1129,21 @@ function SuggestionCard({
</span>
</div>
)}
{/* 召回冷静期 / 暂缓 — snoozedUntil 未到期时提示客服"到点再跟,别现在打" */}
{plan.snoozedUntil && plan.snoozedUntil.getTime() > Date.now() && (
<div className="flex items-start gap-2 text-[11px] py-0.5 mt-1 bg-teal-50/60 rounded px-1 -mx-1">
<span className="flex-none w-5 h-5 rounded flex items-center justify-center mt-px bg-teal-100 text-teal-700">
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
</svg>
</span>
<span className="w-14 flex-none mt-0.5 text-teal-700 font-semibold">暂缓跟进</span>
<span className="font-medium flex-1 leading-snug min-w-0 text-teal-900 break-words">
{snoozeLabel(plan.snoozedUntil)}
</span>
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between">
<span className="text-[10.5px] text-slate-500">
......@@ -1130,6 +1169,56 @@ function SuggestionCard({
);
}
/// 日期 → "M月D日"(使用者习惯,不用 7/9 这种易歧义格式)
function mdLabel(d: Date): string {
return `${d.getMonth() + 1}${d.getDate()}日`;
}
/// snooze 到期时间 → 人话。永久哨兵(>~3 年)显示"长期暂缓",否则显示日期 + 剩余天数。
function snoozeLabel(until: Date): string {
const days = Math.ceil((until.getTime() - Date.now()) / 86_400_000);
if (days > 1000) return '长期暂缓(已闭环 / 标记无效)';
return `${mdLabel(until)} 后再跟进(约 ${days} 天)`;
}
/// RecallHistoryCard — 患者级召回历史(跨所有 plan 版本)。
/// 价值:plan 再次被召回时,客服看得到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
function RecallHistoryCard({
history,
fmtRel,
}: {
history: RecallHistoryItem[];
fmtRel: typeof mockFmtRel;
}) {
return (
<SidebarCard title="召回历史" meta={`${history.length} 次`}>
<div className="space-y-1.5">
{history.map((h) => {
const meta = EXECUTION_OUTCOME_META[h.outcome as ExecutionOutcome];
// 统一灰色样式(不按结果分色)— 跟"未接通"一致,弱化历史、突出当前
return (
<div key={h.id} className="flex items-start gap-2 text-[11px] py-0.5">
<span className="flex-none mt-[3px] w-1.5 h-1.5 rounded-full bg-slate-300" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-slate-600">{meta?.labelZh ?? h.outcome}</span>
<span className="text-slate-400 tabular-nums">{fmtRel(new Date(h.createdAt))}</span>
{h.scheduledNextAt && (
<span className="text-slate-400">· 约 {mdLabel(new Date(h.scheduledNextAt))}回访</span>
)}
</div>
{h.notes && (
<div className="text-slate-500 leading-snug break-words mt-0.5">{h.notes}</div>
)}
</div>
</div>
);
})}
</div>
</SidebarCard>
);
}
/**
* 计算风险规避提示 — PAC 已有数据驱动
*
......
......@@ -51,6 +51,9 @@ export type PlanDetailData = {
assigneeUserId: string | null;
assignedAt: string | null;
recycleAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
/// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: string | null;
/// 该 plan 版本重算时间 — UI "更新于 X" 渲染数据新鲜度
updatedAt: string;
reasons: Array<{
......@@ -169,4 +172,15 @@ export type PlanDetailData = {
markdown: string;
}>;
} | null;
/// 召回历史(患者级,跨所有 plan 版本,最近 8 条)。
/// 用途:plan 再次被召回时,客服看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
recallHistory?: Array<{
id: string;
outcome: string; // raw enum,前端用 EXECUTION_OUTCOME_META 翻译 label/tone
channel: string;
notes: string | null;
scheduledNextAt: string | null;
createdAt: string;
planVersion: number | null;
}>;
};
......@@ -87,7 +87,7 @@ const DENSITY = {
const STATUS_META: Record<string, { label: string; tone: string }> = {
active: { label: '待认领', tone: 'bg-sky-100 text-sky-700' },
assigned: { label: '我的工单', tone: 'bg-amber-100 text-amber-700' },
assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' },
completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' },
abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' },
superseded: { label: '已替代', tone: 'bg-slate-100 text-slate-600' },
......
'use client';
import * as React from 'react';
import { DayPicker } from 'react-day-picker';
import 'react-day-picker/style.css';
import { cn } from '@/lib/utils';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
/// shadcn 风格日历(react-day-picker v10)。teal 主题靠覆写 rdp 内置 CSS 变量,
/// 透传所有 DayPicker props(mode / captionLayout / disabled / startMonth / endMonth …)。
export function Calendar({ className, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays
className={cn('p-2', className)}
style={
{
'--rdp-accent-color': '#0d9488',
'--rdp-accent-background-color': '#f0fdfa',
'--rdp-day-width': '2rem',
'--rdp-day-height': '2rem',
'--rdp-day_button-width': '2rem',
'--rdp-day_button-height': '2rem',
'--rdp-font-family': 'inherit',
fontSize: '12.5px',
} as React.CSSProperties
}
{...props}
/>
);
}
'use client';
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { Button } from './button';
import { Calendar } from './calendar';
import { Popover, PopoverTrigger, PopoverContent } from './popover';
/// 纯日期选择器(shadcn「Date Picker」模式:outline Button + ChevronDown 触发 → Popover + Calendar)。
/// 时间另用 <input type="time">,不在此组件内(对齐 shadcn Date Picker with Time 示例)。
export function DatePicker({
value,
onChange,
placeholder = '选择日期',
min,
}: {
value?: Date;
onChange: (d: Date | undefined) => void;
placeholder?: string;
min?: Date;
}) {
const [open, setOpen] = React.useState(false);
// 年份下拉范围:当前月 → +2 年(回访不会更远),captionLayout=dropdown 用
const endMonth = new Date(new Date().getFullYear() + 2, 11);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between font-normal h-9 text-[12.5px]">
{value ? (
`${value.getFullYear()}年${value.getMonth() + 1}月${value.getDate()}日`
) : (
<span className="text-slate-400">{placeholder}</span>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={value}
captionLayout="dropdown"
defaultMonth={value ?? min}
startMonth={min}
endMonth={endMonth}
disabled={min ? { before: min } : undefined}
onSelect={(d) => {
onChange(d);
if (d) setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-auto rounded-md border border-slate-200 bg-white p-2 shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
......@@ -84,7 +84,7 @@ PAC 的算法连成一条流水线,客服拿到的是排好序、带背景、带
**每种病的默认调参值**(下表是**出厂默认**,列出来作基线对照——万一线上行为跟预期偏了,先核是不是配置被调过):
| 诊断码 | 起步分(base) | 冷静期(天)<br/>诊断后多久才召 | 黄金窗末(天)<br/>超过开始衰减 | 紧迫临界(天)<br/>超过加紧迫分 |
|---|---|---|---|---|
| -------- | --------- | ------------------ | ------------------ | ------------------ |
| K08 缺失牙 | **60** | 30 | 180 | 120 |
| K07 正畸 | 55 | 30 | 365 | 180 |
| K04 根管 | 52 | 14 | 60 | 45 |
......
......@@ -515,39 +515,93 @@ export type ExecutionOutcomeDrives = 'completed' | 'abandoned' | 'keep';
/// outcome 的 UI tone(对应 shadcn 调色板),前端按按钮渲染
export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose';
/// ExecutionOutcome 单一真理源(label / tone / 状态机)
/// "永久"抑制的天数哨兵(患者级粒度下 = 该患者长期不再自动召回,除非人工重激活)。
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
/// 短冷静期后允许重新进池(后续可接换渠道策略)。
export const BREAKER_SUPPRESS_DAYS = 30;
/// 通话结果分组(UI 归类)。三大类正好对应状态机三态 + 列表三个 tab:
/// 结案 close → completed →「已完成」 / 保持 keep → keep →「进行中」 / 放弃 give_up → abandoned →「已放弃」
export type ExecutionOutcomeGroup = 'close' | 'keep' | 'give_up';
/// 分组元数据(label / 显示顺序 / tone)。outcome-form 据此渲染分组标题。
export const EXECUTION_OUTCOME_GROUP_META: Record<
ExecutionOutcomeGroup,
{ labelZh: string; order: number; tone: ExecutionOutcomeTone }
> = {
close: { labelZh: '结案', order: 1, tone: 'emerald' },
keep: { labelZh: '保持', order: 2, tone: 'amber' },
give_up: { labelZh: '放弃', order: 3, tone: 'rose' },
};
/// ExecutionOutcome 单一真理源(group / label / tone / 状态机 / 抑制窗)
///
/// 用法:
/// - 前端 outcome-form 直接遍历这个 map 渲染按钮 + state hint
/// - 前端 outcome-form 按 group 2 级渲染(大类 → 具体项)+ state hint
/// - 后端 execution.service.ts OUTCOME_TO_STATUS 派生自 drivesStatus
/// → 前后端口径完全对齐,改 enum / 改 drivesStatus 一处改完处处生效
/// - 后端 execution.service.ts 用 suppressDays 算 snoozedUntil(回访冷静期 / 终态抑制窗)
/// → 前后端口径完全对齐,改 enum / group / drivesStatus / suppressDays 一处改完处处生效
///
/// 顺序 = UI 显示顺序(JS 对象迭代保留插入顺序),按业务"喜好度 / 终态优先"排:
/// 1. 成功类(emerald)→ 2. 在途类(amber/sky)→ 3. 未触达(slate)→ 4. 终态拒绝(rose)
/// suppressDays 语义(召回闭环核心):提交该 outcome 后,患者多久内不再自动进召回池
/// - 数字 N — 写 snoozedUntil = now + N 天;到期自愈(重新可召回 / 重进池)
/// - null — 不设固定冷静期(若执行带 scheduledNextAt,则按该时间 snooze;否则维持现状)
/// - PERMANENT — 真闭环(外院治疗 / 无效),患者级长期不再召回
/// 标准档默认值(后续可接 tenant.rules_config 覆盖):
/// 明确拒绝/近期不考虑=90d;成功转化/客服放弃=60d;外院/无效=永久;(熔断另算=30d)
///
/// hiddenInForm:历史 enum 值,新建执行不再展示(如 rescheduled 已并入"约定下次回访"),
/// 但保留在 map 里让历史 plan_executions 仍能翻译 label。
export const EXECUTION_OUTCOME_META: Record<
ExecutionOutcome,
{ labelZh: string; tone: ExecutionOutcomeTone; drivesStatus: ExecutionOutcomeDrives }
{
group: ExecutionOutcomeGroup;
labelZh: string;
tone: ExecutionOutcomeTone;
drivesStatus: ExecutionOutcomeDrives;
/// 抑制天数:number=固定冷静期 / null=不固定(看 scheduledNextAt)
suppressDays: number | null;
/// 新建执行表单是否隐藏(历史值兼容用)
hiddenInForm?: boolean;
}
> = {
// ── 成功转化 ──
success_appointed: { labelZh: '成功转化为新预约', tone: 'emerald', drivesStatus: 'completed' },
// ── 在途(下次再跟)──
scheduled_next: { labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep' },
considering: { labelZh: '考虑中,近期再跟进', tone: 'amber', drivesStatus: 'keep' },
needs_doctor: { labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep' },
rescheduled: { labelZh: '改约', tone: 'sky', drivesStatus: 'keep' },
pending_info: { labelZh: '需进一步确认信息', tone: 'slate', drivesStatus: 'keep' },
// ── 未触达 / 弱触达 ──
no_answer: { labelZh: '未接通', tone: 'slate', drivesStatus: 'keep' },
sms_sent: { labelZh: '电话未接,已发短信', tone: 'slate', drivesStatus: 'keep' },
// ── 终态(客户决策)──
declined_recent: { labelZh: '近期不考虑', tone: 'rose', drivesStatus: 'keep' },
refused: { labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'completed' },
external_treatment: { labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'completed' },
// ── 终态(系统决策)──
marked_invalid: { labelZh: '标记为无效', tone: 'rose', drivesStatus: 'abandoned' },
abandoned: { labelZh: '客服放弃', tone: 'rose', drivesStatus: 'abandoned' },
// ── 结案(completed,「已完成」tab)──
success_appointed: { group: 'close', labelZh: '成功转化为新预约', tone: 'emerald', drivesStatus: 'completed', suppressDays: 60 },
declined_recent: { group: 'close', labelZh: '近期不考虑', tone: 'amber', drivesStatus: 'completed', suppressDays: 90 }, // 软结案:90d 后信号级自动复活
// ── 保持(keep,留工单「进行中」)──
scheduled_next: { group: 'keep', labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日
no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null },
considering: { group: 'keep', labelZh: '待跟进', tone: 'sky', drivesStatus: 'keep', suppressDays: null }, // 通用待跟进(细节进纪要)
// ── 放弃(abandoned,「已放弃」tab)──
refused: { group: 'give_up', labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 90 },
external_treatment: { group: 'give_up', labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
marked_invalid: { group: 'give_up', labelZh: '无效', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
// ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ──
rescheduled: { group: 'keep', labelZh: '改约', tone: 'amber', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, // 已并入"约定下次回访"
sms_sent: { group: 'keep', labelZh: '未接通·已发短信', tone: 'slate', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
needs_doctor: { group: 'keep', labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
pending_info: { group: 'keep', labelZh: '需进一步确认信息', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
abandoned: { group: 'give_up', labelZh: '客服放弃', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 60, hiddenInForm: true },
};
/// 召回反馈(选填·多选)— 一线客服对"这条召回本身准不准"的反馈,**正交于通话结果**。
/// 目的:把一线经验变成算法改进数据 —— 每个 tag 对应一个可改的召回算法维度,可聚合定位系统性问题。
/// info_wrong → 信号抽取 / 数据质量(反查 extraction 规则 / 反馈 DW)
/// already_handled → 排除闸 ⑤a/⑤b 漏(同步延迟 / 补排除)
/// duplicate_contact → 冷静期 / 去重(调 cooldown / 抑制窗)
/// bad_timing → 黄金窗 / 入池窗(调 windowDays)
/// not_worth → 打分 / 场景纳入(调 base 分 / scenario)
export const RECALL_FEEDBACK_OPTIONS = [
{ value: 'info_wrong', labelZh: '信息有误', hint: '诊断/牙位/姓名/电话不对' },
{ value: 'already_handled', labelZh: '其实已处理', hint: '本院/外院做过·已有预约' },
{ value: 'duplicate_contact', labelZh: '重复联系', hint: '最近已联系过' },
{ value: 'bad_timing', labelZh: '时机不对', hint: '太早 / 太晚' },
{ value: 'not_worth', labelZh: '不值得召', hint: '价值低 / 不是真机会' },
] as const;
export type RecallFeedbackTag = (typeof RECALL_FEEDBACK_OPTIONS)[number]['value'];
export const ExecutionChannel = {
PHONE: 'phone',
WECOM: 'wecom',
......
......@@ -45,6 +45,9 @@ export const FollowupPlanSchema = z.object({
evidence: PlanEvidenceSchema,
status: PlanStatusSchema,
recycleAt: z.string().nullable(),
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
/// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
snoozedUntil: z.string().nullable(),
/// 实际指派给谁(宿主侧 user id);展示用 name 通过 token 字典查
assigneeUserId: z.string().nullable(),
assignedAt: z.string().nullable(),
......@@ -111,6 +114,8 @@ export const PlanExecutionSchema = z.object({
abandonReasons: z.array(z.string()),
abandonOther: z.string().nullable(),
scheduledNextAt: z.string().nullable(),
/// 召回反馈(选填·多选)— 对召回算法准确性的反馈,正交于 outcome(见 RECALL_FEEDBACK_OPTIONS)
recallFeedback: z.array(z.string()),
createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
});
export type PlanExecution = z.infer<typeof PlanExecutionSchema>;
......@@ -214,6 +219,8 @@ export const SubmitExecutionRequestSchema = z.object({
abandonReasons: z.array(AbandonReasonSchema).max(20).optional(),
abandonOther: z.string().optional(),
scheduledNextAt: z.string().optional(),
/// 召回反馈 tag(选填·多选,见 RECALL_FEEDBACK_OPTIONS)
recallFeedback: z.array(z.string()).max(10).optional(),
});
export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;
......
......@@ -232,6 +232,9 @@ importers:
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
......@@ -247,6 +250,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.4.0
version: 4.4.0
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
......@@ -256,6 +262,9 @@ importers:
react:
specifier: ^19.2.5
version: 19.2.5
react-day-picker:
specifier: ^10.0.1
version: 10.0.1(@types/react@19.2.14)(react@19.2.5)
react-dom:
specifier: ^19.2.5
version: 19.2.5(react@19.2.5)
......@@ -582,6 +591,9 @@ packages:
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
......@@ -1678,6 +1690,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
......@@ -3157,6 +3182,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
......@@ -4937,6 +4965,16 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
react-day-picker@10.0.1:
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
......@@ -6148,6 +6186,8 @@ snapshots:
enabled: 2.0.0
kuler: 2.0.0
'@date-fns/tz@1.5.0': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
......@@ -7251,6 +7291,29 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
......@@ -8790,6 +8853,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.4.0: {}
debug@3.2.7:
dependencies:
ms: 2.1.3
......@@ -9049,8 +9114,8 @@ snapshots:
'@next/eslint-plugin-next': 16.2.4
eslint: 9.39.4(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0))
......@@ -9072,7 +9137,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
......@@ -9083,22 +9148,22 @@ snapshots:
tinyglobby: 0.2.16
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
......@@ -9109,7 +9174,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
......@@ -10892,6 +10957,14 @@ snapshots:
defu: 6.1.7
destr: 2.0.5
react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@date-fns/tz': 1.5.0
date-fns: 4.4.0
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
......
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