Files
hms/apps/web/src/pages/health/FollowUpTaskList.tsx
iven e4e5ef04d4 feat(web): Web 前端功能完善 — API 扩展 + 组件优化
- 新增 AI 透析分析 API + 药物提醒 API
- MediaPicker/ThemeSwitcher/usePaginatedData 优化
- 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等)
- PluginCRUDPage 导入优化
2026-05-13 23:28:22 +08:00

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>
);
}