4 个表单从 Modal 升级为 DrawerForm: - 患者表单:4 分组(基本信息/联系方式/医疗信息/紧急联系人),编辑时获取完整详情 - 预约表单:3 分组(患者信息/医生与排班/备注),保留排班校验逻辑 - 随访填写:2 分组(执行信息/详细记录),DrawerForm 内部校验 - 积分商品:2 分组(基本信息/展示设置) 统一使用 DrawerForm 组件管理表单实例、校验和提交
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Space,
|
||
Modal,
|
||
Form,
|
||
Select,
|
||
DatePicker,
|
||
TimePicker,
|
||
Input,
|
||
Dropdown,
|
||
message,
|
||
Alert,
|
||
} 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';
|
||
import { AuthButton } from '../../components/AuthButton';
|
||
import { PageContainer } from '../../components/PageContainer';
|
||
import { DrawerForm } from '../../components/DrawerForm';
|
||
import { EntityName } from '../../components/EntityName';
|
||
import { formatDateTime } from '../../utils/format';
|
||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||
|
||
/** 预约类型选项 */
|
||
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: '重新确认' },
|
||
],
|
||
};
|
||
|
||
/** 筛选器类型 */
|
||
interface AppointmentFilters {
|
||
status: string | undefined;
|
||
dateRange: [Dayjs | null, Dayjs | null] | null;
|
||
patientSearch: string;
|
||
appointmentType: string | undefined;
|
||
}
|
||
|
||
export default function AppointmentList() {
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
// 患者选择状态(受控组件,不挂在 Form.Item 上)
|
||
const [selectedPatientId, setSelectedPatientId] = useState<string | undefined>(undefined);
|
||
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
|
||
|
||
// 排班校验
|
||
const [scheduleHint, setScheduleHint] = useState<string | null>(null);
|
||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||
|
||
// ---- 数据获取 ----
|
||
const fetcher = useCallback(
|
||
async (page: number, pageSize: number, filters: AppointmentFilters) => {
|
||
const dateStart = filters.dateRange?.[0]?.format('YYYY-MM-DD');
|
||
const dateEnd = filters.dateRange?.[1]?.format('YYYY-MM-DD');
|
||
return appointmentApi.list({
|
||
page,
|
||
page_size: pageSize,
|
||
status: filters.status || undefined,
|
||
date: dateStart === dateEnd ? dateStart : undefined,
|
||
patient_id: undefined, // 后端暂不支持 patientSearch 文本搜索
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const {
|
||
data,
|
||
total,
|
||
page,
|
||
loading,
|
||
filters,
|
||
setFilters,
|
||
refresh,
|
||
} = usePaginatedData<Appointment, AppointmentFilters>(fetcher, {
|
||
pageSize: 20,
|
||
defaultFilters: {
|
||
status: undefined,
|
||
dateRange: null,
|
||
patientSearch: '',
|
||
appointmentType: undefined,
|
||
},
|
||
});
|
||
|
||
const handleFilterChange = useCallback(
|
||
(key: keyof AppointmentFilters, value: unknown) => {
|
||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||
refresh(1);
|
||
},
|
||
[setFilters, refresh],
|
||
);
|
||
|
||
const resetFilters = useCallback(() => {
|
||
setFilters({
|
||
status: undefined,
|
||
dateRange: null,
|
||
patientSearch: '',
|
||
appointmentType: undefined,
|
||
});
|
||
refresh(1);
|
||
}, [setFilters, refresh]);
|
||
|
||
// ---- 状态变更 ----
|
||
const DESTRUCTIVE_STATUSES = new Set(['cancelled', 'no_show']);
|
||
|
||
const handleStatusChange = (record: Appointment, newStatus: string) => {
|
||
const transition = STATUS_TRANSITIONS[record.status]?.find((t) => t.value === newStatus);
|
||
if (!transition) return;
|
||
|
||
if (DESTRUCTIVE_STATUSES.has(newStatus)) {
|
||
let cancelReason = '';
|
||
Modal.confirm({
|
||
title: `确认${transition.label}`,
|
||
content: newStatus === 'cancelled' ? (
|
||
<Input.TextArea
|
||
rows={3}
|
||
placeholder="请输入取消原因"
|
||
onChange={(e) => { cancelReason = e.target.value; }}
|
||
/>
|
||
) : (
|
||
<span>确定将此预约标记为"{transition.label}"?</span>
|
||
),
|
||
okText: '确认',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await appointmentApi.updateStatus(record.id, {
|
||
status: newStatus,
|
||
version: record.version,
|
||
...(newStatus === 'cancelled' && { cancel_reason: cancelReason }),
|
||
});
|
||
message.success('状态更新成功');
|
||
refresh();
|
||
} catch {
|
||
message.error('状态更新失败');
|
||
}
|
||
},
|
||
});
|
||
} else {
|
||
Modal.confirm({
|
||
title: `确认${transition.label}`,
|
||
content: `确定将此预约状态变更为"${transition.label}"?`,
|
||
okText: '确认',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await appointmentApi.updateStatus(record.id, {
|
||
status: newStatus,
|
||
version: record.version,
|
||
});
|
||
message.success('状态更新成功');
|
||
refresh();
|
||
} catch {
|
||
message.error('状态更新失败');
|
||
}
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
// ---- 新建预约 ----
|
||
const openCreate = () => {
|
||
setSelectedPatientId(undefined);
|
||
setSelectedDoctorId(undefined);
|
||
setScheduleHint(null);
|
||
setSelectedDate(null);
|
||
setDrawerOpen(true);
|
||
};
|
||
|
||
// 排班校验:医生 + 日期选定后查询排班
|
||
useEffect(() => {
|
||
if (!selectedDoctorId || !selectedDate || !drawerOpen) {
|
||
setScheduleHint(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
appointmentApi.listSchedules({ doctor_id: selectedDoctorId, date: selectedDate, page: 1, page_size: 50 })
|
||
.then((result) => {
|
||
if (cancelled) return;
|
||
const schedules = result.data;
|
||
if (schedules.length === 0) {
|
||
setScheduleHint(`该医生在 ${selectedDate} 暂无排班,请确认是否需要先创建排班`);
|
||
} else {
|
||
const slots = schedules
|
||
.filter((s) => s.status === 'active' && s.current_appointments < s.max_appointments)
|
||
.map((s) => `${s.start_time}-${s.end_time}(${s.current_appointments}/${s.max_appointments})`)
|
||
.join('、');
|
||
setScheduleHint(slots ? `可约时段:${slots}` : `该医生在 ${selectedDate} 排班已满或已停用`);
|
||
}
|
||
})
|
||
.catch(() => { if (!cancelled) setScheduleHint(null); });
|
||
return () => { cancelled = true; };
|
||
}, [selectedDoctorId, selectedDate, drawerOpen]);
|
||
|
||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||
if (!selectedPatientId) {
|
||
message.warning('请选择患者');
|
||
return;
|
||
}
|
||
if (!selectedDoctorId) {
|
||
message.warning('请选择医护');
|
||
return;
|
||
}
|
||
try {
|
||
setSubmitting(true);
|
||
const req: CreateAppointmentReq = {
|
||
patient_id: selectedPatientId,
|
||
doctor_id: selectedDoctorId,
|
||
appointment_date: (values.appointment_date as Dayjs).format('YYYY-MM-DD'),
|
||
start_time: (values.start_time as Dayjs).format('HH:mm'),
|
||
end_time: (values.end_time as Dayjs).format('HH:mm'),
|
||
appointment_type: (values.appointment_type as string) || 'outpatient',
|
||
notes: (values.notes as string) || undefined,
|
||
};
|
||
await appointmentApi.create(req);
|
||
message.success('预约创建成功');
|
||
setDrawerOpen(false);
|
||
setSelectedPatientId(undefined);
|
||
setSelectedDoctorId(undefined);
|
||
refresh();
|
||
} catch {
|
||
message.error('创建预约失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// ---- 列定义 ----
|
||
const columns = [
|
||
{
|
||
title: '患者',
|
||
dataIndex: 'patient_name',
|
||
key: 'patient_name',
|
||
width: 100,
|
||
render: (_: unknown, record: Appointment) => (
|
||
<EntityName name={record.patient_name} id={record.patient_id} />
|
||
),
|
||
},
|
||
{
|
||
title: '医护',
|
||
dataIndex: 'doctor_name',
|
||
key: 'doctor_name',
|
||
width: 100,
|
||
render: (_: unknown, record: Appointment) => (
|
||
<EntityName name={record.doctor_name} id={record.doctor_id} />
|
||
),
|
||
},
|
||
{
|
||
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: 'created_at',
|
||
key: 'created_at',
|
||
width: 180,
|
||
render: (val: string) => formatDateTime(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 (
|
||
<AuthButton code="health.appointment.manage">
|
||
<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>
|
||
</AuthButton>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<PageContainer
|
||
title="预约管理"
|
||
filters={
|
||
<Space wrap>
|
||
<Select
|
||
placeholder="筛选状态"
|
||
value={filters.status}
|
||
onChange={(val) => handleFilterChange('status', val)}
|
||
options={STATUS_OPTIONS}
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
/>
|
||
<DatePicker.RangePicker
|
||
value={filters.dateRange as [Dayjs, Dayjs] | null}
|
||
onChange={(dates) => handleFilterChange('dateRange', dates)}
|
||
allowClear
|
||
/>
|
||
<Input
|
||
placeholder="搜索患者"
|
||
value={filters.patientSearch}
|
||
onChange={(e) => handleFilterChange('patientSearch', e.target.value)}
|
||
allowClear
|
||
style={{ width: 180 }}
|
||
/>
|
||
<Select
|
||
placeholder="预约类型"
|
||
value={filters.appointmentType}
|
||
onChange={(val) => handleFilterChange('appointmentType', val)}
|
||
options={APPOINTMENT_TYPE_OPTIONS}
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
/>
|
||
</Space>
|
||
}
|
||
onResetFilters={resetFilters}
|
||
actions={
|
||
<AuthButton code="health.appointment.manage">
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||
新建预约
|
||
</Button>
|
||
</AuthButton>
|
||
}
|
||
>
|
||
<Table
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
scroll={{ x: 1200 }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize: 20,
|
||
total,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p) => refresh(p),
|
||
}}
|
||
/>
|
||
|
||
{/* 新建预约弹窗 */}
|
||
<DrawerForm
|
||
title="新建预约"
|
||
open={drawerOpen}
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
setSelectedPatientId(undefined);
|
||
setSelectedDoctorId(undefined);
|
||
setScheduleHint(null);
|
||
}}
|
||
onSubmit={handleSubmit}
|
||
loading={submitting}
|
||
width={640}
|
||
columns={2}
|
||
initialValues={{ appointment_type: 'outpatient' }}
|
||
sections={[
|
||
{
|
||
title: '患者信息',
|
||
fields: (
|
||
<>
|
||
<Form.Item label="患者" required>
|
||
<PatientSelect
|
||
value={selectedPatientId}
|
||
onChange={(val) => setSelectedPatientId(val)}
|
||
placeholder="搜索选择患者"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="appointment_type" label="预约类型" initialValue="outpatient">
|
||
<Select options={APPOINTMENT_TYPE_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="appointment_date"
|
||
label="预约日期"
|
||
rules={[{ required: true, message: '请选择预约日期' }]}
|
||
>
|
||
<DatePicker style={{ width: '100%' }} onChange={(d) => setSelectedDate(d ? d.format('YYYY-MM-DD') : null)} />
|
||
</Form.Item>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
title: '医生与排班',
|
||
fields: (
|
||
<>
|
||
<Form.Item label="医护" required>
|
||
<DoctorSelect
|
||
value={selectedDoctorId}
|
||
onChange={(val) => setSelectedDoctorId(val)}
|
||
placeholder="搜索选择医护"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="start_time"
|
||
label="开始时间"
|
||
rules={[{ required: true, message: '请选择开始时间' }]}
|
||
>
|
||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="end_time"
|
||
label="结束时间"
|
||
rules={[{ required: true, message: '请选择结束时间' }]}
|
||
>
|
||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
{scheduleHint && (
|
||
<Alert
|
||
message={scheduleHint}
|
||
type={scheduleHint.includes('暂无排班') ? 'warning' : 'info'}
|
||
showIcon
|
||
style={{ marginBottom: 16, gridColumn: '1 / -1' }}
|
||
/>
|
||
)}
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
title: '备注',
|
||
fields: (
|
||
<Form.Item name="notes" label="备注" style={{ gridColumn: '1 / -1' }}>
|
||
<Input.TextArea rows={3} placeholder="预约备注信息" />
|
||
</Form.Item>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</PageContainer>
|
||
);
|
||
}
|