feat(health): Phase 4 跨模块集成与架构优化 — 通知/标签/待办/数据录入
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

后端:
- erp-message: 添加 appointment.created/confirmed/cancelled 事件监听,自动发送站内通知
- erp-health: 新增 GET /health/patient-tags 标签列表端点 + list_tags service
- wechat-templates: 添加 isTemplateConfigured 运行时校验

前端:
- 新增 Zustand useHealthStore 共享患者/医生名称缓存
- PatientTagManage: UUID 输入替换为 Checkbox 标签选择器
- VitalSignsTab: 添加体征数据录入 Modal (血压/心率/体重/血糖)
- LabReportsTab: 添加化验报告创建 Modal
- HealthRecordsTab: 添加健康记录创建 Modal
- patients API: 添加 TagItem 类型 + listTags 方法

小程序:
- 首页待办事项接入预约和随访 API,替换硬编码 EmptyState
This commit is contained in:
iven
2026-04-25 20:10:50 +08:00
parent 5b520a168c
commit d2baacae7e
14 changed files with 667 additions and 222 deletions

View File

@@ -76,6 +76,13 @@ export interface CreateFamilyMemberReq {
notes?: string;
}
export interface TagItem {
id: string;
name: string;
color: string | null;
description: string | null;
}
// --- API ---
export const patientApi = {
list: async (params: {
@@ -180,4 +187,12 @@ export const patientApi = {
removeDoctor: async (id: string, doctorId: string) => {
await client.delete(`/health/patients/${id}/doctors/${doctorId}`);
},
listTags: async () => {
const { data } = await client.get<{
success: boolean;
data: TagItem[];
}>('/health/patient-tags');
return data.data;
},
};

View File

@@ -4,14 +4,14 @@ import {
Button,
Space,
Modal,
Input,
Tag,
Card,
Checkbox,
message,
Typography,
} from 'antd';
import { TagsOutlined, AppstoreOutlined } from '@ant-design/icons';
import { patientApi } from '../../api/health/patients';
import { patientApi, type TagItem } from '../../api/health/patients';
import type { PatientListItem } from '../../api/health/patients';
import { useThemeMode } from '../../hooks/useThemeMode';
@@ -22,7 +22,8 @@ export default function PatientTagManage() {
const [loading, setLoading] = useState(false);
const [tagModalOpen, setTagModalOpen] = useState(false);
const [selectedPatient, setSelectedPatient] = useState<PatientListItem | null>(null);
const [tagInput, setTagInput] = useState('');
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [allTags, setAllTags] = useState<TagItem[]>([]);
const [saving, setSaving] = useState(false);
const isDark = useThemeMode();
@@ -42,28 +43,34 @@ export default function PatientTagManage() {
[page],
);
const fetchTags = useCallback(async () => {
try {
const tags = await patientApi.listTags();
setAllTags(tags);
} catch {
// 标签列表加载失败不阻塞页面
}
}, []);
useEffect(() => {
fetchPatients();
}, [fetchPatients]);
fetchTags();
}, [fetchPatients, fetchTags]);
const openTagModal = (record: PatientListItem) => {
setSelectedPatient(record);
setTagInput('');
const existingTags = (record as PatientListItem & { tag_ids?: string[] }).tag_ids || [];
setSelectedTagIds(existingTags);
setTagModalOpen(true);
};
const handleSaveTags = async () => {
if (!selectedPatient) return;
const tagIds = tagInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
setSaving(true);
try {
await patientApi.manageTags(selectedPatient.id, tagIds);
await patientApi.manageTags(selectedPatient.id, selectedTagIds);
message.success('标签更新成功');
setTagModalOpen(false);
setTagInput('');
fetchPatients();
} catch {
message.error('标签更新失败');
@@ -72,6 +79,12 @@ export default function PatientTagManage() {
}
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
);
};
const columns = [
{
title: '患者姓名',
@@ -121,18 +134,22 @@ export default function PatientTagManage() {
}
return (
<Space size={4} wrap>
{tagIds.map((t) => (
<Tag
key={t}
style={{
background: isDark ? '#0f172a' : '#f0f9ff',
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
color: isDark ? '#7dd3fc' : '#0369a1',
}}
>
{t}
</Tag>
))}
{tagIds.map((t) => {
const tagDef = allTags.find((at) => at.id === t);
return (
<Tag
key={t}
color={tagDef?.color || undefined}
style={tagDef?.color ? undefined : {
background: isDark ? '#0f172a' : '#f0f9ff',
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
color: isDark ? '#7dd3fc' : '#0369a1',
}}
>
{tagDef?.name || t.slice(0, 8)}
</Tag>
);
})}
</Space>
);
},
@@ -175,7 +192,6 @@ export default function PatientTagManage() {
return (
<div>
{/* 说明卡片 */}
<Card
style={{
marginBottom: 16,
@@ -185,25 +201,18 @@ export default function PatientTagManage() {
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<AppstoreOutlined
style={{ fontSize: 20, color: '#0ea5e9', marginTop: 2 }}
/>
<AppstoreOutlined style={{ fontSize: 20, color: '#0ea5e9', marginTop: 2 }} />
<div>
<Typography.Text strong style={{ fontSize: 14 }}>
</Typography.Text>
<Typography.Paragraph
type="secondary"
style={{ margin: '4px 0 0', fontSize: 13 }}
>
ID
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0', fontSize: 13 }}>
便
</Typography.Paragraph>
</div>
</div>
</Card>
{/* 页面标题 */}
<div className="erp-page-header">
<div>
<h4></h4>
@@ -211,7 +220,6 @@ export default function PatientTagManage() {
</div>
</div>
{/* 表格容器 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
@@ -239,29 +247,31 @@ export default function PatientTagManage() {
/>
</div>
{/* 标签管理弹窗 */}
<Modal
title={`管理标签 - ${selectedPatient?.name || ''}`}
open={tagModalOpen}
onCancel={() => {
setTagModalOpen(false);
setTagInput('');
}}
onCancel={() => setTagModalOpen(false)}
onOk={handleSaveTags}
confirmLoading={saving}
okText="保存"
width={440}
>
<div style={{ marginTop: 16 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
ID
</Typography.Paragraph>
<Input
placeholder="例如: tag-001, tag-002, tag-003"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onPressEnter={handleSaveTags}
/>
{allTags.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{allTags.map((tag) => (
<Checkbox
key={tag.id}
checked={selectedTagIds.includes(tag.id)}
onChange={() => toggleTag(tag.id)}
>
<Tag color={tag.color || undefined}>{tag.name}</Tag>
</Checkbox>
))}
</div>
)}
</div>
</Modal>
</div>

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useCallback, useState } from 'react';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { HealthRecord } from '../../../api/health/healthData';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
@@ -9,69 +10,91 @@ interface Props {
patientId: string;
}
const columns: ColumnsType<HealthRecord> = [
{
title: '记录类型',
dataIndex: 'record_type',
key: 'record_type',
width: 120,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 120,
},
{
title: '内容',
dataIndex: 'content',
key: 'content',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
const columns = [
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
];
/**
* 健康档案标签页 — 分页表格
*/
export function HealthRecordsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const fetcher = useCallback(
async (page: number, pageSize: number) => {
return healthDataApi.listHealthRecords(patientId, {
page,
page_size: pageSize,
});
return healthDataApi.listHealthRecords(patientId, { page, page_size: pageSize });
},
[patientId],
);
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(
fetcher,
10,
);
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(fetcher, 10);
const handleCreate = async (values: {
record_type: string;
record_date: Dayjs;
content?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createHealthRecord(patientId, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
});
message.success('健康记录添加成功');
setModalOpen(false);
form.resetFields();
refresh();
} catch {
message.error('添加失败');
} finally {
setSubmitting(false);
}
};
return (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
</Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page, total, pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
<Modal
title="添加健康记录"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="record_type" label="记录类型" rules={[{ required: true, message: '请输入类型' }]}>
<Input placeholder="如:门诊记录、出院小结、体检报告" />
</Form.Item>
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="content" label="内容">
<Input.TextArea rows={4} placeholder="健康记录详细内容" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useCallback, useState } from 'react';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { LabReport } from '../../../api/health/healthData';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
@@ -9,69 +10,91 @@ interface Props {
patientId: string;
}
const columns: ColumnsType<LabReport> = [
{
title: '报告日期',
dataIndex: 'report_date',
key: 'report_date',
width: 120,
},
{
title: '报告类型',
dataIndex: 'report_type',
key: 'report_type',
width: 120,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: '医生解读',
dataIndex: 'doctor_interpretation',
key: 'doctor_interpretation',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
const columns = [
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
];
/**
* 化验报告标签页 — 分页表格
*/
export function LabReportsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const fetcher = useCallback(
async (page: number, pageSize: number) => {
return healthDataApi.listLabReports(patientId, {
page,
page_size: pageSize,
});
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
},
[patientId],
);
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(
fetcher,
10,
);
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
const handleCreate = async (values: {
report_date: Dayjs;
report_type: string;
doctor_interpretation?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createLabReport(patientId, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
});
message.success('化验报告添加成功');
setModalOpen(false);
form.resetFields();
refresh();
} catch {
message.error('添加失败');
} finally {
setSubmitting(false);
}
};
return (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
</Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page, total, pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
<Modal
title="添加化验报告"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="report_type" label="报告类型" rules={[{ required: true, message: '请选择类型' }]}>
<Input placeholder="如:血常规、生化全套" />
</Form.Item>
<Form.Item name="doctor_interpretation" label="医生解读">
<Input.TextArea rows={3} placeholder="检查结果解读备注" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useCallback, useState } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { VitalSigns } from '../../../api/health/healthData';
import { VitalSignsChart } from './VitalSignsChart';
@@ -10,71 +11,71 @@ interface Props {
patientId: string;
}
const columns: ColumnsType<VitalSigns> = [
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 120,
},
{
title: '收缩压(晨)',
dataIndex: 'systolic_bp_morning',
key: 'systolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '舒张压(晨)',
dataIndex: 'diastolic_bp_morning',
key: 'diastolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '心率',
dataIndex: 'heart_rate',
key: 'heart_rate',
width: 80,
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
},
{
title: '体重',
dataIndex: 'weight',
key: 'weight',
width: 80,
render: (v?: number) => (v != null ? `${v} kg` : '-'),
},
{
title: '血糖',
dataIndex: 'blood_sugar',
key: 'blood_sugar',
width: 80,
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
},
const columns = [
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
{ title: '收缩压(晨)', dataIndex: 'systolic_bp_morning', key: 'systolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') },
{ title: '舒张压(晨)', dataIndex: 'diastolic_bp_morning', key: 'diastolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') },
{ title: '心率', dataIndex: 'heart_rate', key: 'heart_rate', width: 80, render: (v?: number) => (v != null ? `${v} bpm` : '-') },
{ title: '体重', dataIndex: 'weight', key: 'weight', width: 80, render: (v?: number) => (v != null ? `${v} kg` : '-') },
{ title: '血糖', dataIndex: 'blood_sugar', key: 'blood_sugar', width: 80, render: (v?: number) => (v != null ? `${v} mmol/L` : '-') },
];
/**
* 体征数据标签页 — 含趋势图 + 分页表格
*/
export function VitalSignsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const fetcher = useCallback(
async (page: number, pageSize: number) => {
return healthDataApi.listVitalSigns(patientId, {
page,
page_size: pageSize,
});
return healthDataApi.listVitalSigns(patientId, { page, page_size: pageSize });
},
[patientId],
);
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(
fetcher,
10,
);
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
const handleCreate = async (values: {
record_date: Dayjs;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createVitalSigns(patientId, {
record_date: values.record_date.format('YYYY-MM-DD'),
systolic_bp_morning: values.systolic_bp_morning,
diastolic_bp_morning: values.diastolic_bp_morning,
heart_rate: values.heart_rate,
weight: values.weight,
blood_sugar: values.blood_sugar,
water_intake_ml: values.water_intake_ml,
urine_output_ml: values.urine_output_ml,
notes: values.notes,
});
message.success('体征数据录入成功');
setModalOpen(false);
form.resetFields();
refresh();
} catch {
message.error('录入失败');
} finally {
setSubmitting(false);
}
};
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
</Button>
</div>
<div style={{ marginBottom: 16 }}>
<VitalSignsChart patientId={patientId} />
</div>
@@ -85,14 +86,50 @@ export function VitalSignsTab({ patientId }: Props) {
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: 10,
current: page, total, pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
<Modal
title="录入体征数据"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={600}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Form.Item name="systolic_bp_morning" label="收缩压(晨) mmHg">
<InputNumber min={50} max={300} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="diastolic_bp_morning" label="舒张压(晨) mmHg">
<InputNumber min={30} max={200} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="heart_rate" label="心率 bpm">
<InputNumber min={30} max={250} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="weight" label="体重 kg">
<InputNumber min={1} max={500} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="blood_sugar" label="血糖 mmol/L">
<InputNumber min={0} max={50} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="water_intake_ml" label="饮水量 ml">
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { create } from 'zustand';
import { patientApi } from '../api/health/patients';
import { doctorApi } from '../api/health/doctors';
interface HealthState {
patientNames: Record<string, string>;
doctorNames: Record<string, string>;
loadingIds: Set<string>;
resolvePatientName: (id: string) => Promise<string>;
resolveDoctorName: (id: string) => Promise<string>;
getPatientName: (id: string) => string;
getDoctorName: (id: string) => string;
}
export const useHealthStore = create<HealthState>((set, get) => ({
patientNames: {},
doctorNames: {},
loadingIds: new Set(),
resolvePatientName: async (id: string) => {
const { patientNames, loadingIds } = get();
if (patientNames[id]) return patientNames[id];
if (loadingIds.has(`p:${id}`)) return id.slice(0, 8);
const newLoading = new Set(loadingIds);
newLoading.add(`p:${id}`);
set({ loadingIds: newLoading });
try {
const detail = await patientApi.get(id);
const name = detail.name;
set((s) => ({
patientNames: { ...s.patientNames, [id]: name },
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)),
}));
return name;
} catch {
set((s) => ({
patientNames: { ...s.patientNames, [id]: id.slice(0, 8) },
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)),
}));
return id.slice(0, 8);
}
},
resolveDoctorName: async (id: string) => {
const { doctorNames, loadingIds } = get();
if (doctorNames[id]) return doctorNames[id];
if (loadingIds.has(`d:${id}`)) return id.slice(0, 8);
const newLoading = new Set(loadingIds);
newLoading.add(`d:${id}`);
set({ loadingIds: newLoading });
try {
const detail = await doctorApi.get(id);
const name = detail.name;
set((s) => ({
doctorNames: { ...s.doctorNames, [id]: name },
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)),
}));
return name;
} catch {
set((s) => ({
doctorNames: { ...s.doctorNames, [id]: id.slice(0, 8) },
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)),
}));
return id.slice(0, 8);
}
},
getPatientName: (id: string) => get().patientNames[id] || id.slice(0, 8),
getDoctorName: (id: string) => get().doctorNames[id] || id.slice(0, 8),
}));