- 新增 AI 透析分析 API + 药物提醒 API - MediaPicker/ThemeSwitcher/usePaginatedData 优化 - 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等) - PluginCRUDPage 导入优化
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Table,
|
|
Select,
|
|
Button,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
DatePicker,
|
|
Space,
|
|
Popconfirm,
|
|
} from 'antd';
|
|
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
|
import { dayjs } from '../../utils/dayjs';
|
|
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
|
|
import { StatusTag } from './components/StatusTag';
|
|
import { PatientSelect } from './components/PatientSelect';
|
|
import { DoctorSelect } from './components/DoctorSelect';
|
|
import { AuthButton } from '../../components/AuthButton';
|
|
import { PageContainer } from '../../components/PageContainer';
|
|
import { EntityName } from '../../components/EntityName';
|
|
import { DrawerForm } from '../../components/DrawerForm';
|
|
import { formatDate, formatDateTime } from '../../utils/format';
|
|
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
|
import { useApiRequest } from '../../hooks/useApiRequest';
|
|
import { useDictionary } from '../../hooks/useDictionary';
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'pending', label: '待处理' },
|
|
{ value: 'in_progress', label: '进行中' },
|
|
{ value: 'completed', label: '已完成' },
|
|
{ value: 'overdue', label: '逾期' },
|
|
{ value: 'cancelled', label: '已取消' },
|
|
];
|
|
|
|
const FOLLOW_UP_TYPE_FALLBACK = [
|
|
{ value: 'phone', label: '电话' },
|
|
{ value: 'outpatient', label: '门诊' },
|
|
{ value: 'home_visit', label: '家访' },
|
|
{ value: 'visit', label: '上门' },
|
|
{ value: 'online', label: '线上' },
|
|
{ value: 'wechat', label: '微信' },
|
|
];
|
|
|
|
const FOLLOW_UP_TYPE_MAP: Record<string, string> = {
|
|
phone: '电话',
|
|
outpatient: '门诊',
|
|
home_visit: '家访',
|
|
visit: '上门',
|
|
online: '线上',
|
|
wechat: '微信',
|
|
};
|
|
|
|
interface FollowUpFilters {
|
|
status?: string;
|
|
dateRange?: [string, string];
|
|
followUpType?: string;
|
|
assigneeId?: string;
|
|
}
|
|
|
|
interface AssignFormValues {
|
|
assigned_to: string;
|
|
}
|
|
|
|
export default function FollowUpTaskList() {
|
|
const { options: FOLLOW_UP_TYPE_OPTIONS } = useDictionary('health_follow_up_type', FOLLOW_UP_TYPE_FALLBACK);
|
|
const [searchParams] = useSearchParams();
|
|
const urlPatientId = searchParams.get('patient_id');
|
|
|
|
// --- Paginated data with usePaginatedData ---
|
|
const fetchFn = useCallback(
|
|
async (page: number, pageSize: number, filters: FollowUpFilters) => {
|
|
const params: Record<string, unknown> = { page, page_size: pageSize };
|
|
if (filters.status) params.status = filters.status;
|
|
if (filters.followUpType) params.follow_up_type = filters.followUpType;
|
|
if (filters.assigneeId) params.assigned_to = filters.assigneeId;
|
|
if (urlPatientId) params.patient_id = urlPatientId;
|
|
if (filters.dateRange) {
|
|
params.planned_date_start = filters.dateRange[0];
|
|
params.planned_date_end = filters.dateRange[1];
|
|
}
|
|
return followUpApi.listTasks(params as Parameters<typeof followUpApi.listTasks>[0]);
|
|
},
|
|
[urlPatientId],
|
|
);
|
|
|
|
const {
|
|
data: tasks,
|
|
total,
|
|
page,
|
|
loading,
|
|
filters,
|
|
setFilters,
|
|
refresh,
|
|
} = usePaginatedData<FollowUpTask, FollowUpFilters>(fetchFn, {
|
|
pageSize: 20,
|
|
defaultFilters: {},
|
|
});
|
|
|
|
const { execute } = useApiRequest();
|
|
|
|
// Create task modal
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [createLoading, setCreateLoading] = useState(false);
|
|
const [createForm] = Form.useForm<CreateFollowUpTaskReq>();
|
|
|
|
// Fill record modal
|
|
const [recordOpen, setRecordOpen] = useState(false);
|
|
const [recordLoading, setRecordLoading] = useState(false);
|
|
const [activeTask, setActiveTask] = useState<FollowUpTask | null>(null);
|
|
|
|
// Assign modal
|
|
const [assignOpen, setAssignOpen] = useState(false);
|
|
const [assignLoading, setAssignLoading] = useState(false);
|
|
const [assignForm] = Form.useForm<AssignFormValues>();
|
|
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
|
|
|
|
// --- Handlers ---
|
|
const handleTableChange = (pagination: TablePaginationConfig) => {
|
|
refresh(pagination.current ?? 1);
|
|
};
|
|
|
|
// Create task
|
|
const handleCreate = async () => {
|
|
let values: CreateFollowUpTaskReq;
|
|
try {
|
|
values = await createForm.validateFields();
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
|
return;
|
|
}
|
|
setCreateLoading(true);
|
|
const plannedDate = values.planned_date;
|
|
const result = await execute(
|
|
() => followUpApi.createTask({
|
|
patient_id: values.patient_id,
|
|
follow_up_type: values.follow_up_type,
|
|
planned_date: dayjs.isDayjs(plannedDate) ? plannedDate.format('YYYY-MM-DD') : plannedDate,
|
|
assigned_to: values.assigned_to,
|
|
content_template: values.content_template,
|
|
}),
|
|
'随访任务创建成功',
|
|
);
|
|
setCreateLoading(false);
|
|
if (result !== null) {
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
refresh(page);
|
|
}
|
|
};
|
|
|
|
// Fill record
|
|
const openRecordModal = (task: FollowUpTask) => {
|
|
setActiveTask(task);
|
|
setRecordOpen(true);
|
|
};
|
|
|
|
const handleRecordSubmit = async (values: Record<string, unknown>) => {
|
|
if (!activeTask) return;
|
|
setRecordLoading(true);
|
|
const result = await execute(
|
|
() => followUpApi.createRecord(activeTask.id, {
|
|
executed_date: (values.executed_date as dayjs.Dayjs).format('YYYY-MM-DD'),
|
|
result: values.result as string,
|
|
patient_condition: values.patient_condition as string,
|
|
medical_advice: values.medical_advice as string,
|
|
next_follow_up_date: values.next_follow_up_date
|
|
? (values.next_follow_up_date as dayjs.Dayjs).format('YYYY-MM-DD')
|
|
: undefined,
|
|
}),
|
|
'随访记录填写成功',
|
|
);
|
|
setRecordLoading(false);
|
|
if (result !== null) {
|
|
setRecordOpen(false);
|
|
setActiveTask(null);
|
|
refresh(page);
|
|
}
|
|
};
|
|
|
|
// Assign
|
|
const openAssignModal = (task: FollowUpTask) => {
|
|
setAssignTask(task);
|
|
assignForm.resetFields();
|
|
if (task.assigned_to) {
|
|
assignForm.setFieldsValue({ assigned_to: task.assigned_to });
|
|
}
|
|
setAssignOpen(true);
|
|
};
|
|
|
|
const handleAssign = async () => {
|
|
if (!assignTask) return;
|
|
let values: { assigned_to: string };
|
|
try {
|
|
values = await assignForm.validateFields();
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
|
return;
|
|
}
|
|
setAssignLoading(true);
|
|
const req: UpdateFollowUpTaskReq & { version: number } = {
|
|
assigned_to: values.assigned_to,
|
|
version: assignTask.version,
|
|
};
|
|
const result = await execute(
|
|
() => followUpApi.updateTask(assignTask.id, req),
|
|
'分配成功',
|
|
);
|
|
setAssignLoading(false);
|
|
if (result !== null) {
|
|
setAssignOpen(false);
|
|
setAssignTask(null);
|
|
refresh(page);
|
|
}
|
|
};
|
|
|
|
// Delete
|
|
const handleDelete = async (record: FollowUpTask) => {
|
|
const result = await execute(
|
|
() => followUpApi.deleteTask(record.id, record.version),
|
|
'删除成功',
|
|
);
|
|
if (result !== null) refresh(page);
|
|
};
|
|
|
|
// --- Columns ---
|
|
const columns: ColumnsType<FollowUpTask> = [
|
|
{
|
|
title: '患者',
|
|
dataIndex: 'patient_name',
|
|
key: 'patient_name',
|
|
width: 140,
|
|
render: (_: unknown, record: FollowUpTask) => (
|
|
<EntityName name={record.patient_name} id={record.patient_id} />
|
|
),
|
|
},
|
|
{
|
|
title: '随访类型',
|
|
dataIndex: 'follow_up_type',
|
|
key: 'follow_up_type',
|
|
width: 100,
|
|
render: (v: string) => FOLLOW_UP_TYPE_MAP[v] || v,
|
|
},
|
|
{
|
|
title: '计划日期',
|
|
dataIndex: 'planned_date',
|
|
key: 'planned_date',
|
|
width: 120,
|
|
render: (v: string) => formatDate(v),
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 100,
|
|
render: (status: string) => <StatusTag status={status} />,
|
|
},
|
|
{
|
|
title: '负责人',
|
|
dataIndex: 'assigned_to',
|
|
key: 'assigned_to',
|
|
width: 140,
|
|
render: (_: unknown, record: FollowUpTask) => (
|
|
<EntityName
|
|
name={record.assigned_to_name}
|
|
id={record.assigned_to}
|
|
fallbackLabel="未分配"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: '创建时间',
|
|
dataIndex: 'created_at',
|
|
key: 'created_at',
|
|
width: 160,
|
|
render: (v: string) => formatDateTime(v),
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'actions',
|
|
width: 220,
|
|
render: (_: unknown, record: FollowUpTask) => (
|
|
<AuthButton code="health.follow-up.manage">
|
|
<Space size={4}>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => openRecordModal(record)}
|
|
>
|
|
填写记录
|
|
</Button>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<SwapOutlined />}
|
|
onClick={() => openAssignModal(record)}
|
|
>
|
|
分配
|
|
</Button>
|
|
<Popconfirm
|
|
title="确认删除该随访任务?"
|
|
onConfirm={() => handleDelete(record)}
|
|
okText="确认"
|
|
cancelText="取消"
|
|
>
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
</AuthButton>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageContainer
|
|
title="随访管理"
|
|
subtitle={`共 ${total} 条`}
|
|
filters={
|
|
<>
|
|
<Select
|
|
allowClear
|
|
placeholder="状态筛选"
|
|
style={{ width: 140 }}
|
|
options={STATUS_OPTIONS}
|
|
value={filters.status}
|
|
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
|
|
/>
|
|
<DatePicker.RangePicker
|
|
style={{ width: 240 }}
|
|
onChange={(dates) => {
|
|
if (dates && dates[0] && dates[1]) {
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
|
|
}));
|
|
} else {
|
|
setFilters((prev) => ({ ...prev, dateRange: undefined }));
|
|
}
|
|
}}
|
|
/>
|
|
<Select
|
|
allowClear
|
|
placeholder="随访类型"
|
|
style={{ width: 120 }}
|
|
options={FOLLOW_UP_TYPE_OPTIONS}
|
|
value={filters.followUpType}
|
|
onChange={(value) => setFilters((prev) => ({ ...prev, followUpType: value }))}
|
|
/>
|
|
<Select
|
|
allowClear
|
|
placeholder="负责人"
|
|
style={{ width: 140 }}
|
|
// Note: Assignee select would ideally use DoctorSelect options
|
|
// but Select with async search needs separate handling
|
|
value={filters.assigneeId}
|
|
onChange={(value) => setFilters((prev) => ({ ...prev, assigneeId: value }))}
|
|
/>
|
|
</>
|
|
}
|
|
onResetFilters={() => setFilters({})}
|
|
actions={
|
|
<AuthButton code="health.follow-up.manage">
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => {
|
|
createForm.resetFields();
|
|
setCreateOpen(true);
|
|
}}
|
|
>
|
|
新建任务
|
|
</Button>
|
|
</AuthButton>
|
|
}
|
|
>
|
|
<Table
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={tasks}
|
|
loading={loading}
|
|
onChange={handleTableChange}
|
|
pagination={{
|
|
current: page,
|
|
pageSize: 20,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
scroll={{ x: 980 }}
|
|
/>
|
|
|
|
{/* Create Task Modal */}
|
|
<Modal
|
|
title="新建随访任务"
|
|
open={createOpen}
|
|
onOk={handleCreate}
|
|
onCancel={() => setCreateOpen(false)}
|
|
confirmLoading={createLoading}
|
|
okText="创建"
|
|
cancelText="取消"
|
|
destroyOnHidden
|
|
>
|
|
<Form form={createForm} layout="vertical" autoComplete="off">
|
|
<Form.Item
|
|
name="patient_id"
|
|
label="患者"
|
|
rules={[{ required: true, message: '请选择患者' }]}
|
|
>
|
|
<PatientSelect />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="follow_up_type"
|
|
label="随访类型"
|
|
rules={[{ required: true, message: '请选择随访类型' }]}
|
|
>
|
|
<Select options={FOLLOW_UP_TYPE_OPTIONS} placeholder="选择随访类型" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="planned_date"
|
|
label="计划日期"
|
|
rules={[{ required: true, message: '请选择计划日期' }]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="assigned_to" label="负责人">
|
|
<DoctorSelect />
|
|
</Form.Item>
|
|
<Form.Item name="content_template" label="内容模板">
|
|
<Input.TextArea rows={3} placeholder="随访内容模板(可选)" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Fill Record Drawer */}
|
|
<DrawerForm
|
|
title={`填写随访记录 — 任务 ${activeTask?.id?.slice(0, 8) ?? ''}`}
|
|
open={recordOpen}
|
|
onClose={() => {
|
|
setRecordOpen(false);
|
|
setActiveTask(null);
|
|
}}
|
|
onSubmit={handleRecordSubmit}
|
|
loading={recordLoading}
|
|
width={560}
|
|
columns={1}
|
|
sections={[
|
|
{
|
|
title: '执行信息',
|
|
fields: (
|
|
<>
|
|
<Form.Item
|
|
name="executed_date"
|
|
label="执行日期"
|
|
rules={[{ required: true, message: '请选择执行日期' }]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="result"
|
|
label="随访结果"
|
|
rules={[{ required: true, message: '请填写随访结果' }]}
|
|
>
|
|
<Input.TextArea rows={3} placeholder="描述随访结果" />
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
title: '详细记录',
|
|
fields: (
|
|
<>
|
|
<Form.Item name="patient_condition" label="患者状况">
|
|
<Input.TextArea rows={3} placeholder="描述患者当前状况" />
|
|
</Form.Item>
|
|
<Form.Item name="medical_advice" label="医嘱">
|
|
<Input.TextArea rows={3} placeholder="医嘱内容" />
|
|
</Form.Item>
|
|
<Form.Item name="next_follow_up_date" label="下次随访日期">
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Assign Modal */}
|
|
<Modal
|
|
title="分配负责人"
|
|
open={assignOpen}
|
|
onOk={handleAssign}
|
|
onCancel={() => {
|
|
setAssignOpen(false);
|
|
setAssignTask(null);
|
|
}}
|
|
confirmLoading={assignLoading}
|
|
okText="确认"
|
|
cancelText="取消"
|
|
destroyOnHidden
|
|
>
|
|
<Form form={assignForm} layout="vertical" autoComplete="off">
|
|
<Form.Item
|
|
name="assigned_to"
|
|
label="负责人"
|
|
rules={[{ required: true, message: '请选择负责人' }]}
|
|
>
|
|
<DoctorSelect />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|