Files
hms/apps/web/src/pages/health/FollowUpTaskList.tsx
iven ac919731a9
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
fix: QA 全量测试发现 5 个 bug 修复
- [P0] 登录失败无反馈: client.ts 响应拦截器跳过 /auth/login 的 401 处理,让错误传播到 Login 组件
- [P0] 统计仪表盘 400: 前端用独立 try/catch 替代 Promise.all 提高容错性;后端 stats_service 白名单补充 ultrafiltration_volume/dialysis_duration
- [P1] 随访负责人显示 UUID: 批量解析 assigned_to 用户名
- [P2] 消息中心时间未格式化: 添加 formatDateTime 函数
- [P2] 首页显示 login_failed: 过滤审计日志中的 login_failed 动作
2026-04-26 23:48:22 +08:00

548 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
Table,
Select,
Button,
Modal,
Form,
Input,
DatePicker,
Space,
Popconfirm,
message,
} from 'antd';
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
import { patientApi } from '../../api/health/patients';
import { getUser } from '../../api/users';
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
const STATUS_OPTIONS = [
{ value: 'pending', label: '待处理' },
{ value: 'in_progress', label: '进行中' },
{ value: 'completed', label: '已完成' },
{ value: 'overdue', label: '逾期' },
{ value: 'cancelled', label: '已取消' },
];
const FOLLOW_UP_TYPE_OPTIONS = [
{ value: 'phone', label: '电话' },
{ value: 'outpatient', label: '门诊' },
{ value: 'home_visit', label: '家访' },
{ value: 'wechat', label: '微信' },
];
const FOLLOW_UP_TYPE_MAP: Record<string, string> = {
phone: '电话',
outpatient: '门诊',
home_visit: '家访',
wechat: '微信',
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
interface RecordFormValues {
executed_date: dayjs.Dayjs;
result: string;
patient_condition: string;
medical_advice: string;
next_follow_up_date?: dayjs.Dayjs;
}
interface AssignFormValues {
assigned_to: string;
}
export default function FollowUpTaskList() {
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<{ page: number; page_size: number; status?: string }>({
page: 1,
page_size: 20,
});
// 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 [recordForm] = Form.useForm<RecordFormValues>();
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);
// Patient/doctor label cache for display
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
const isDark = useThemeMode();
// --- Data fetching ---
const fetchTasks = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
setLoading(true);
try {
const result = await followUpApi.listTasks(params);
setTasks(result.data);
setTotal(result.total);
// Batch resolve patient names
const patientIds = [...new Set(result.data.map((t: FollowUpTask) => t.patient_id).filter(Boolean))];
const newLabels: Record<string, string> = {};
await Promise.allSettled(
patientIds.map(async (id: string) => {
try {
const p = await patientApi.get(id);
newLabels[id] = p.name;
} catch { /* skip */ }
}),
);
if (Object.keys(newLabels).length > 0) {
setPatientLabels((prev) => ({ ...prev, ...newLabels }));
}
// Batch resolve assignee names
const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter(Boolean))];
const newDoctorLabels: Record<string, string> = {};
await Promise.allSettled(
assigneeIds.map(async (id: string) => {
try {
const u = await getUser(id);
newDoctorLabels[id] = u.display_name || u.username;
} catch { /* skip */ }
}),
);
if (Object.keys(newDoctorLabels).length > 0) {
setDoctorLabels((prev) => ({ ...prev, ...newDoctorLabels }));
}
} catch {
message.error('加载随访任务失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTasks(query);
}, [query, fetchTasks]);
// --- Handlers ---
const handleFilterChange = (field: 'status', value: string | undefined) => {
setQuery((prev) => ({ ...prev, [field]: value || undefined, page: 1 }));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current ?? 1,
page_size: pagination.pageSize ?? 20,
}));
};
// Create task
const handleCreate = async () => {
try {
const values = await createForm.validateFields();
setCreateLoading(true);
const plannedDate = values.planned_date;
await 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,
});
message.success('随访任务创建成功');
setCreateOpen(false);
createForm.resetFields();
fetchTasks(query);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return; // form validation
message.error('创建随访任务失败');
} finally {
setCreateLoading(false);
}
};
// Fill record
const openRecordModal = (task: FollowUpTask) => {
setActiveTask(task);
recordForm.resetFields();
setRecordOpen(true);
};
const handleRecordSubmit = async () => {
if (!activeTask) return;
try {
const values = await recordForm.validateFields();
setRecordLoading(true);
await followUpApi.createRecord(activeTask.id, {
executed_date: values.executed_date.format('YYYY-MM-DD'),
result: values.result,
patient_condition: values.patient_condition,
medical_advice: values.medical_advice,
next_follow_up_date: values.next_follow_up_date?.format('YYYY-MM-DD'),
});
message.success('随访记录填写成功');
setRecordOpen(false);
setActiveTask(null);
fetchTasks(query);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return;
message.error('填写随访记录失败');
} finally {
setRecordLoading(false);
}
};
// 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;
try {
const values = await assignForm.validateFields();
setAssignLoading(true);
const req: UpdateFollowUpTaskReq & { version: number } = {
assigned_to: values.assigned_to,
version: assignTask.version,
};
await followUpApi.updateTask(assignTask.id, req);
message.success('分配成功');
setAssignOpen(false);
setAssignTask(null);
fetchTasks(query);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return;
message.error('分配失败');
} finally {
setAssignLoading(false);
}
};
// Delete
const handleDelete = async (record: FollowUpTask) => {
try {
await followUpApi.deleteTask(record.id, record.version);
message.success('删除成功');
fetchTasks(query);
} catch {
message.error('删除失败');
}
};
// Store labels from selects
const handlePatientLabel = (id: string, label: string) => {
setPatientLabels((prev) => ({ ...prev, [id]: label }));
};
const handleDoctorLabel = (id: string, label: string) => {
setDoctorLabels((prev) => ({ ...prev, [id]: label }));
};
// --- Columns ---
const columns: ColumnsType<FollowUpTask> = [
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (id: string) => patientLabels[id] || id.slice(0, 8),
},
{
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) => v,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => <StatusTag status={status} />,
},
{
title: '负责人',
dataIndex: 'assigned_to',
key: 'assigned_to',
width: 140,
render: (id: string | undefined) =>
id ? doctorLabels[id] || id.slice(0, 8) : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => (
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(v)}
</span>
),
},
{
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 (
<div>
{/* Toolbar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}
>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 160 }}
options={STATUS_OPTIONS}
value={query.status}
onChange={(value) => handleFilterChange('status', value)}
/>
<AuthButton code="health.follow-up.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
}}
>
</Button>
</AuthButton>
<span
style={{
fontSize: 13,
color: isDark ? '#475569' : '#94a3b8',
marginLeft: 'auto',
}}
>
{total}
</span>
</div>
{/* Table */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Table
rowKey="id"
columns={columns}
dataSource={tasks}
loading={loading}
onChange={handleTableChange}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 980 }}
/>
</div>
{/* Create Task Modal */}
<Modal
title="新建随访任务"
open={createOpen}
onOk={handleCreate}
onCancel={() => setCreateOpen(false)}
confirmLoading={createLoading}
okText="创建"
cancelText="取消"
destroyOnClose
>
<Form form={createForm} layout="vertical" autoComplete="off">
<Form.Item
name="patient_id"
label="患者"
rules={[{ required: true, message: '请选择患者' }]}
>
<PatientSelect
onChange={(_val, label) => handlePatientLabel(_val, label)}
/>
</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
onChange={(_val, label) => handleDoctorLabel(_val, label)}
/>
</Form.Item>
<Form.Item name="content_template" label="内容模板">
<Input.TextArea rows={3} placeholder="随访内容模板(可选)" />
</Form.Item>
</Form>
</Modal>
{/* Fill Record Modal */}
<Modal
title={`填写随访记录 — 任务 ${activeTask?.id?.slice(0, 8) ?? ''}`}
open={recordOpen}
onOk={handleRecordSubmit}
onCancel={() => {
setRecordOpen(false);
setActiveTask(null);
}}
confirmLoading={recordLoading}
okText="提交"
cancelText="取消"
destroyOnClose
width={560}
>
<Form form={recordForm} layout="vertical" autoComplete="off">
<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>
<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>
</Form>
</Modal>
{/* Assign Modal */}
<Modal
title="分配负责人"
open={assignOpen}
onOk={handleAssign}
onCancel={() => {
setAssignOpen(false);
setAssignTask(null);
}}
confirmLoading={assignLoading}
okText="确认"
cancelText="取消"
destroyOnClose
>
<Form form={assignForm} layout="vertical" autoComplete="off">
<Form.Item
name="assigned_to"
label="负责人"
rules={[{ required: true, message: '请选择负责人' }]}
>
<DoctorSelect
onChange={(_val, label) => handleDoctorLabel(_val, label)}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}