Commit afd4d8cc by luoqi

refactor(中间表): 三个控件并成一个搜索框 —— 姓名/病历号/医生/客服 一起搜

原来是「搜索框 + 医生下拉 + 客服下拉」三件。并成一个:输入任意词,
姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服名 任一命中即可(全部服务端 ILIKE)。

 别再拆回下拉:医生和客服的候选各要拉一个接口(/plans/doctors + 名册),
   而主管想的只是"跟薛玫有关的",他不关心那个名字落在哪一列。
️ 专属客服名直接读 `patients.preferences.dedicatedCs.name`(库里就有),
    不用先查名册再翻 id —— 那要在查询前多一次往返。
️ `%` `_` `\` 转义后再进 pattern:病历号里真有下划线,不转义会变通配符,
   搜「BJ0U_1」把 BJ0UX1 也捞出来 —— 多捞不报错,更难发现。

顺带删掉 NO_OWNER(「无专属客服」那一档随下拉一起没了, 别留没人用的常量)。

本地实测:薛玫→首页 50 条、金芮冰→24 条、BJ0U0175→19 条;
按钮仍然写「确定,交给助手(420 人)」——搜索不改变交出去的人群。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b3334df7
......@@ -9,7 +9,6 @@ import {
type AssignmentProposal,
type ProposedItem,
AnchorMode,
NO_OWNER,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
......@@ -643,17 +642,16 @@ export class AssignmentProposalService {
anchorMode?: AnchorModeValue;
personaTags?: string;
/**
* 🔴 下面三个是**看**的条件,⛔ 不是圈人条件 —— 它们只收窄这张表显示什么,
* 交给助手的仍然是整格的人(`total`)。
* 这条区分必须在界面上说清楚(见 CohortPreview 的按钮文案):
* 主管筛到 12 人然后点确定、结果分下去 420 人,是这功能最容易出的误解。
* 🔴 **一个搜索框打通五个字段**(2026-08-11 产品定,原来是"搜索框 + 医生下拉 + 客服下拉"三件)。
* 命中任一即可:患者姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服名。
*
* ⚠️ 这是**看**的条件,⛔ 不是圈人条件 —— 只收窄这张表显示什么,
* 交给助手的仍然是整格的人(`total`)。这条区分必须在界面上说清楚
* (见 CohortPreview 的按钮文案):主管筛到 12 人然后点确定、结果分下去 420 人,
* 是这功能最容易出的误解。
* ⚠️ 医生**两列都匹配**(主治 / 上次接诊):主管说"薛玫的患者"时不分这两者。
*/
/// 姓名 / 病历号 模糊查(在这批人里找某个人)
keyword?: string;
/// 医生:主治**或**上次就诊任一命中(候选来自 /plans/doctors,精确匹配)
doctor?: string;
/// 专属客服 userId;`__none__` = 只看无主的
agentUserId?: string;
page?: number;
pageSize?: number;
},
......@@ -683,38 +681,24 @@ export class AssignmentProposalService {
* ⚠️ 医生两列任一命中:主管说"薛玫的患者"时不区分主治还是上次接诊,
* 分两个筛选反而逼他猜(而且他多半两个都想要)。
*/
const esc = (v: string) => v.replace(/([%_\\])/g, '\\$1');
const kw = input.keyword?.trim();
const viewFilter = Prisma.sql`
${
kw
? Prisma.sql`AND (p.name ILIKE ${`%${esc(kw)}%`} ESCAPE '\\'
OR p.medical_record_number ILIKE ${`%${esc(kw)}%`} ESCAPE '\\')`
: Prisma.empty
}
${
input.doctor
? Prisma.sql`AND EXISTS (SELECT 1 FROM patient_profiles vpp
WHERE vpp.patient_id = fp.patient_id
AND ${input.doctor} IN (vpp.preferred_doctor, vpp.last_visit_doctor))`
: Prisma.empty
}
${
/**
* 🔴 **先判"传没传",再判"是不是哨兵"** —— ⛔ 别写成 `x === NO_OWNER ? … : x ? … : 空`。
* 2026-08-11 实测:服务进程抱着旧的 `@pac/types` dist 时 `NO_OWNER` 是 `undefined`,
* 而没传筛选时 `input.agentUserId` 也是 `undefined` → 恒等成立 →
* **每一次查询都被悄悄加上"只看无主的"**,420 人的格子只回 20 行,
* count 还是 420(它不走这段)—— 表和总数对不上,且不报任何错。
* 这么写之后,没传就是没传,常量再怎么坏也影响不到"不筛"这条路。
*/
!input.agentUserId
? Prisma.empty
: input.agentUserId === NO_OWNER
? Prisma.sql`AND p.preferences #>> '{dedicatedCs,id}' IS NULL`
: Prisma.sql`AND p.preferences #>> '{dedicatedCs,id}' = ${input.agentUserId}`
}`;
const hasView = Boolean(kw || input.doctor || input.agentUserId);
/**
* ⚠️ `%` `_` `\` 要转义再进 pattern:病历号里真有下划线,不转义会变成通配符,
* 搜「BJ0U_1」把 BJ0UX1 也捞出来 —— 结果多但不报错,更难发现。
*/
const like = kw ? `%${kw.replace(/([%_\\])/g, '\\$1')}%` : '';
const viewFilter = kw
? Prisma.sql`AND (
p.name ILIKE ${like} ESCAPE '\\'
OR p.medical_record_number ILIKE ${like} ESCAPE '\\'
OR p.preferences #>> '{dedicatedCs,name}' ILIKE ${like} ESCAPE '\\'
OR EXISTS (
SELECT 1 FROM patient_profiles vpp
WHERE vpp.patient_id = fp.patient_id
AND (vpp.preferred_doctor ILIKE ${like} ESCAPE '\\'
OR vpp.last_visit_doctor ILIKE ${like} ESCAPE '\\')))`
: Prisma.empty;
const hasView = Boolean(kw);
const [{ total }, filteredRows, rows] = await Promise.all([
// ⭐ 整格的人数(**不含**看的过滤)—— 这才是点确定后真正交出去的量
......
......@@ -193,10 +193,9 @@ export class AssignmentController {
@Query('temperature') temperature?: string,
@Query('anchor') anchor?: string,
@Query('personaTags') personaTags?: string,
/// ↓ 三个「看」的过滤:只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释)
/// ↓ 一个搜索框打通五个字段(姓名/病历号/主治/上次接诊/专属客服):
/// 只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释)
@Query('keyword') keyword?: string,
@Query('doctor') doctor?: string,
@Query('agentUserId') agentUserId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
......@@ -211,8 +210,6 @@ export class AssignmentController {
anchorMode: parseAnchorMode(anchor),
...(personaTags ? { personaTags } : {}),
...(keyword ? { keyword } : {}),
...(doctor ? { doctor } : {}),
...(agentUserId ? { agentUserId } : {}),
...(page ? { page: Number(page) } : {}),
...(pageSize ? { pageSize: Number(pageSize) } : {}),
});
......
......@@ -142,10 +142,9 @@ export const assignmentsApi = {
temperature?: string;
anchor?: AnchorModeValue;
personaTags?: string;
/// ↓ 三个「看」的条件:只收窄这张表,⛔ 不改变交给助手的人群
/// 一个框搜五样(姓名/病历号/主治/上次接诊/专属客服):只收窄这张表,
/// ⛔ 不改变交给助手的人群
keyword?: string;
doctor?: string;
agentUserId?: string;
page?: number;
pageSize?: number;
}) =>
......@@ -157,8 +156,6 @@ export const assignmentsApi = {
anchor: q.anchor,
personaTags: q.personaTags,
keyword: q.keyword,
doctor: q.doctor,
agentUserId: q.agentUserId,
page: q.page == null ? undefined : String(q.page),
pageSize: q.pageSize == null ? undefined : String(q.pageSize),
},
......
......@@ -4,13 +4,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, X } from 'lucide-react';
import {
ANCHOR_MODE_META,
NO_OWNER,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { assignmentsApi, type CohortPatientRow } from '@/components/plans/assignments-api';
import { plansApi } from '@/components/plans/plans-api';
import { cn, formatGender } from '@/lib/utils';
/**
......@@ -56,32 +54,20 @@ export function CohortPreview({
const okRef = useRef<HTMLButtonElement>(null);
const scroller = useRef<HTMLDivElement>(null);
// ── 三个「看」的条件 ──────────────────────────────────
/// 输入框里的原文 vs 真正发出去的词 —— 分开是为了防抖(每敲一个字发一次请求会打爆后端)
/**
* ⭐ **一个框搜五样**(2026-08-11 产品定,原来是"搜索框 + 医生下拉 + 客服下拉"三件):
* 姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服,任一命中即可。
* ⛔ 别再拆回下拉:医生和客服的候选各要拉一个接口,而主管想的只是"跟薛玫有关的",
* 他不关心那个名字落在哪一列。
* ⚠️ 输入框原文与真正发出去的词分开 —— 防抖 300ms,⛔ 别每敲一个字发一次请求。
*/
const [kwInput, setKwInput] = useState('');
const [keyword, setKeyword] = useState('');
const [doctor, setDoctor] = useState('');
const [agent, setAgent] = useState('');
useEffect(() => {
const t = window.setTimeout(() => setKeyword(kwInput.trim()), 300);
return () => window.clearTimeout(t);
}, [kwInput]);
/// 候选名单:医生走 `/plans/doctors`(服务端缓存 6h),客服走本诊所名册 —— 与筛选面板同源
const [doctors, setDoctors] = useState<string[]>([]);
const [agents, setAgents] = useState<Array<{ userId: string; name: string | null }>>([]);
useEffect(() => {
let alive = true;
void plansApi.doctors().then((r) => alive && setDoctors(r.doctors)).catch(() => undefined);
void assignmentsApi
.agents(clinicId)
.then((r) => alive && setAgents(r.agents.map((a) => ({ userId: a.userId, name: a.name }))))
.catch(() => undefined);
return () => {
alive = false;
};
}, [clinicId]);
/**
* 🔴 **在途守卫必须是 ref,⛔ 不能靠 `loading` 这个 state**(与批次列表同一个坑):
* scroll 事件连发时 `loading` 还是上一帧的快照,挡不住第二、第三次触发 ——
......@@ -101,8 +87,6 @@ export function CohortPreview({
temperature: cell.temperature,
anchor: anchorMode,
...(keyword ? { keyword } : {}),
...(doctor ? { doctor } : {}),
...(agent ? { agentUserId: agent } : {}),
page: p,
pageSize: PAGE_SIZE,
});
......@@ -123,7 +107,7 @@ export function CohortPreview({
setLoading(false);
}
},
[clinicId, cell.treatment, cell.temperature, anchorMode, keyword, doctor, agent],
[clinicId, cell.treatment, cell.temperature, anchorMode, keyword],
);
/// 条件一变就回到第一页并清空 —— ⛔ 不能只追加:那会把新条件的结果接在旧结果后面
......@@ -165,7 +149,7 @@ export function CohortPreview({
}, [onCancel]);
const cellTotal = total ?? cell.count;
const hasFilter = Boolean(keyword || doctor || agent);
const hasFilter = Boolean(keyword);
const shown = filtered ?? cellTotal;
const tag = ANCHOR_MODE_META[anchorMode].batchTag;
......@@ -202,7 +186,7 @@ export function CohortPreview({
</button>
</div>
{/* 筛选条 —— ⚠️ 全部走**服务端**过滤:滚动加载只加载了前几页,
{/* 搜索条 —— ⚠️ 走**服务端**过滤:滚动加载只加载了前几页,
放前端过滤就成了"在已加载的那几页里搜",第 7 页的张三搜不到还不报错。 */}
<div className="flex flex-none flex-wrap items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
<span className="relative">
......@@ -210,55 +194,25 @@ export function CohortPreview({
<input
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
placeholder="姓名 / 病历号"
className="h-7 w-48 rounded-md border bg-white pl-7 pr-2 text-[12px] outline-none placeholder:text-slate-300 focus:border-brand-300"
placeholder="姓名 / 病历号 / 医生 / 客服"
className="h-7 w-72 rounded-md border bg-white pl-7 pr-2 text-[12px] outline-none placeholder:text-slate-300 focus:border-brand-300"
/>
</span>
<select
value={doctor}
onChange={(e) => setDoctor(e.target.value)}
className="h-7 max-w-[11rem] rounded-md border bg-white px-1.5 text-[12px] text-slate-700 outline-none focus:border-brand-300"
>
{/* ⚠️ 医生只有一个下拉:主治与上次接诊**任一命中**即可 ——
主管说「薛玫的患者」时不分这两者,拆成两个筛选反而逼他猜。 */}
<option value="">全部医生</option>
{doctors.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<select
value={agent}
onChange={(e) => setAgent(e.target.value)}
className="h-7 max-w-[11rem] rounded-md border bg-white px-1.5 text-[12px] text-slate-700 outline-none focus:border-brand-300"
>
<option value="">全部客服</option>
{/* ⭐「无专属」是主管真会用的一档:他常想先看"没主的还剩多少" */}
<option value={NO_OWNER}>无专属客服</option>
{agents.map((a) => (
<option key={a.userId} value={a.userId}>
{a.name ?? a.userId}
</option>
))}
</select>
{hasFilter && (
<button
type="button"
onClick={() => {
setKwInput('');
setKeyword('');
setDoctor('');
setAgent('');
}}
className="text-[11.5px] text-brand-700 hover:underline"
>
清空筛选
清空
</button>
)}
{/* 🔴 这句必须在,而且必须在筛选条旁边 —— 见组件头注释 */}
{/* 🔴 这句必须在,而且必须在搜索框旁边 —— 见组件头注释 */}
<span className="ml-auto text-[11px] text-slate-400">
筛选只影响这张表,交给助手的仍是整格 {cellTotal}
搜索只影响这张表,交给助手的仍是整格 {cellTotal}
</span>
</div>
......@@ -273,7 +227,7 @@ export function CohortPreview({
</div>
) : items.length === 0 ? (
<p className="py-10 text-center text-[12px] text-slate-400">
{hasFilter ? '这批人里没有符合筛选条件的' : '这一格没有人'}
{hasFilter ? '这批人里没搜到' : '这一格没有人'}
</p>
) : (
<table className="w-full border-collapse text-[12.5px] text-slate-700">
......
......@@ -158,13 +158,6 @@ export const ANCHOR_MODE_META: Record<
},
};
/**
* 「无专属客服」的筛选哨兵值 —— 前后端共用。
* ⚠️ 用一个不可能与 userId 相撞的串,⛔ 别用空串:空串在查询串里跟"没传这个参数"分不开,
* 而"只看无主的"与"不按客服筛"是两个不同的意思。
*/
export const NO_OWNER = '__none__';
/** 入参解析:非法值一律回落默认(⛔ 别抛错 —— 查询串是用户可改的) */
export function parseAnchorMode(v: unknown): AnchorModeValue {
return v === AnchorMode.LAST_VISIT ? AnchorMode.LAST_VISIT : AnchorMode.DIAGNOSIS;
......
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