Files
hms/apps/web/src/pages/health/AppointmentList.tsx
iven 355e8da272
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(health): 全链路流通性验证修复
- 创建 stub migration 解决缺失文件报错
- PatientList/PatientDetail: DatePicker dayjs 对象序列化为 YYYY-MM-DD
- AppointmentList: 预约类型与后端验证对齐(outpatient/recheck/health_checkup/consultation/dialysis)
- AppointmentList: 医生字段改为必填(后端 CAS 排班要求), destroyOnClose→destroyOnHidden
- Home.tsx: 补充审计日志 action 翻译(created/login_failed 等)

全链路验证通过: 医生CRUD→排班→预约创建+状态流转→随访生命周期→咨询会话+消息→患者详情+健康数据
2026-04-25 11:31:54 +08:00

383 lines
11 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Select,
DatePicker,
TimePicker,
Input,
Dropdown,
message,
Card,
Row,
Col,
} from 'antd';
import {
PlusOutlined,
DownOutlined,
} from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { appointmentApi, type Appointment, type CreateAppointmentReq } from '../../api/health/appointments';
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
/** 预约类型选项 */
const APPOINTMENT_TYPE_OPTIONS = [
{ value: 'outpatient', label: '门诊' },
{ value: 'recheck', label: '复诊' },
{ value: 'health_checkup', label: '体检' },
{ value: 'consultation', label: '咨询' },
{ value: 'dialysis', label: '透析' },
];
const APPOINTMENT_TYPE_MAP: Record<string, string> = {
outpatient: '门诊',
recheck: '复诊',
health_checkup: '体检',
consultation: '咨询',
dialysis: '透析',
};
/** 状态筛选选项 */
const STATUS_OPTIONS = [
{ value: 'pending', label: '待确认' },
{ value: 'confirmed', label: '已确认' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已取消' },
{ value: 'no_show', label: '未到诊' },
];
/** 状态流转规则 */
const STATUS_TRANSITIONS: Record<string, { value: string; label: string }[]> = {
pending: [
{ value: 'confirmed', label: '确认' },
{ value: 'cancelled', label: '取消' },
],
confirmed: [
{ value: 'completed', label: '完成' },
{ value: 'no_show', label: '未到诊' },
{ value: 'cancelled', label: '取消' },
],
completed: [],
cancelled: [],
no_show: [
{ value: 'confirmed', label: '重新确认' },
],
};
export default function AppointmentList() {
const [data, setData] = useState<Appointment[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [dateFilter, setDateFilter] = useState<Dayjs | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
// 患者选择状态(受控组件,不挂在 Form.Item 上)
const [selectedPatientId, setSelectedPatientId] = useState<string | undefined>(undefined);
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const result = await appointmentApi.list({
page: p,
page_size: ps,
status: statusFilter || undefined,
date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined,
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载预约列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, dateFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ---- 状态变更 ----
const handleStatusChange = async (record: Appointment, newStatus: string) => {
try {
await appointmentApi.updateStatus(record.id, {
status: newStatus,
version: record.version,
});
message.success('状态更新成功');
fetchData(page, pageSize);
} catch {
message.error('状态更新失败');
}
};
// ---- 新建预约 ----
const openCreate = () => {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setModalOpen(true);
};
const handleSubmit = async (values: {
appointment_date: Dayjs;
start_time: Dayjs;
end_time: Dayjs;
appointment_type?: string;
notes?: string;
}) => {
if (!selectedPatientId) {
message.warning('请选择患者');
return;
}
if (!selectedDoctorId) {
message.warning('请选择医护');
return;
}
try {
const req: CreateAppointmentReq = {
patient_id: selectedPatientId,
doctor_id: selectedDoctorId || undefined,
appointment_date: values.appointment_date.format('YYYY-MM-DD'),
start_time: values.start_time.format('HH:mm'),
end_time: values.end_time.format('HH:mm'),
appointment_type: values.appointment_type || 'outpatient',
notes: values.notes || undefined,
};
await appointmentApi.create(req);
message.success('预约创建成功');
setModalOpen(false);
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
fetchData(page, pageSize);
} catch {
message.error('创建预约失败');
}
};
// ---- 列定义 ----
const columns = [
{
title: '患者',
dataIndex: 'patient_name',
key: 'patient_name',
width: 100,
render: (_: unknown, record: Appointment) =>
record.patient_name ?? record.patient_id.slice(0, 8),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 100,
render: (_: unknown, record: Appointment) => {
return record.doctor_name || record.doctor_id?.slice(0, 8) || '-';
},
},
{
title: '预约类型',
dataIndex: 'appointment_type',
key: 'appointment_type',
width: 90,
render: (val: string) => APPOINTMENT_TYPE_MAP[val] || val,
},
{
title: '预约日期',
dataIndex: 'appointment_date',
key: 'appointment_date',
width: 120,
render: (val: string) => val || '-',
},
{
title: '时段',
key: 'time_range',
width: 120,
render: (_: unknown, record: Appointment) =>
record.start_time && record.end_time
? `${record.start_time} - ${record.end_time}`
: '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (val: string) => <StatusTag status={val} />,
},
{
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 180,
ellipsis: true,
render: (val: string) => val || '-',
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as const,
render: (_: unknown, record: Appointment) => {
const transitions = STATUS_TRANSITIONS[record.status] || [];
if (transitions.length === 0) {
return <span style={{ color: '#999' }}></span>;
}
return (
<Dropdown
menu={{
items: transitions.map((t) => ({
key: t.value,
label: t.label,
onClick: () => handleStatusChange(record, t.value),
})),
}}
>
<Button type="link" size="small">
<DownOutlined />
</Button>
</Dropdown>
);
},
},
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Select
placeholder="筛选状态"
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<DatePicker
placeholder="筛选日期"
value={dateFilter}
onChange={(val) => {
setDateFilter(val);
setPage(1);
}}
allowClear
/>
</Space>
</Col>
<Col>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Col>
</Row>
{/* 数据表格 */}
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1000 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
{/* 新建预约弹窗 */}
<Modal
title="新建预约"
open={modalOpen}
onCancel={() => {
setModalOpen(false);
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
}}
onOk={() => form.submit()}
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item label="患者" required>
<PatientSelect
value={selectedPatientId}
onChange={(val) => setSelectedPatientId(val)}
placeholder="搜索选择患者"
/>
</Form.Item>
<Form.Item label="医护" required>
<DoctorSelect
value={selectedDoctorId}
onChange={(val) => setSelectedDoctorId(val)}
placeholder="搜索选择医护"
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="appointment_date"
label="预约日期"
rules={[{ required: true, message: '请选择预约日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="appointment_type" label="预约类型" initialValue="outpatient">
<Select options={APPOINTMENT_TYPE_OPTIONS} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="start_time"
label="开始时间"
rules={[{ required: true, message: '请选择开始时间' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="end_time"
label="结束时间"
rules={[{ required: true, message: '请选择结束时间' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} placeholder="预约备注信息" />
</Form.Item>
</Form>
</Modal>
</Card>
);
}