Files
hms/apps/web/src/pages/health/AppointmentList.tsx
iven 24c7f9451f
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
feat(web): 表单升级 — Modal→DrawerForm + 分组双列布局
4 个表单从 Modal 升级为 DrawerForm:
- 患者表单:4 分组(基本信息/联系方式/医疗信息/紧急联系人),编辑时获取完整详情
- 预约表单:3 分组(患者信息/医生与排班/备注),保留排班校验逻辑
- 随访填写:2 分组(执行信息/详细记录),DrawerForm 内部校验
- 积分商品:2 分组(基本信息/展示设置)

统一使用 DrawerForm 组件管理表单实例、校验和提交
2026-04-28 08:40:22 +08:00

518 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}