- 创建 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→排班→预约创建+状态流转→随访生命周期→咨询会话+消息→患者详情+健康数据
383 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|