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