所有 health 页面从 import dayjs from 'dayjs' 迁移到
import { dayjs } from '.../utils/dayjs',确保 relativeTime
和 zh-cn locale 全局生效。
432 lines
12 KiB
TypeScript
432 lines
12 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Table,
|
|
Button,
|
|
Space,
|
|
Modal,
|
|
Form,
|
|
Select,
|
|
DatePicker,
|
|
TimePicker,
|
|
InputNumber,
|
|
Segmented,
|
|
Spin,
|
|
message,
|
|
Card,
|
|
Row,
|
|
Col,
|
|
Empty,
|
|
} from 'antd';
|
|
import { PlusOutlined, EditOutlined } from '@ant-design/icons';
|
|
import { dayjs } from '../../utils/dayjs';
|
|
import type { Dayjs } from 'dayjs';
|
|
import {
|
|
appointmentApi,
|
|
type Schedule,
|
|
type CreateScheduleReq,
|
|
type UpdateScheduleReq,
|
|
type CalendarDay,
|
|
} from '../../api/health/appointments';
|
|
import { DoctorSelect } from './components/DoctorSelect';
|
|
import { CalendarView, type ScheduleItem } from './components/CalendarView';
|
|
import { StatusTag } from './components/StatusTag';
|
|
import { AuthButton } from '../../components/AuthButton';
|
|
|
|
/** 时段选项 */
|
|
const PERIOD_OPTIONS = [
|
|
{ value: 'am', label: '上午' },
|
|
{ value: 'pm', label: '下午' },
|
|
];
|
|
|
|
const PERIOD_LABEL: Record<string, string> = {
|
|
am: '上午',
|
|
pm: '下午',
|
|
};
|
|
|
|
/** 排班状态选项 */
|
|
const SCHEDULE_STATUS_OPTIONS = [
|
|
{ value: 'active', label: '启用' },
|
|
{ value: 'inactive', label: '停用' },
|
|
{ value: 'cancelled', label: '已取消' },
|
|
];
|
|
|
|
export default function DoctorSchedule() {
|
|
// ---- 状态 ----
|
|
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
|
|
const [data, setData] = useState<Schedule[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(50);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// 视图模式
|
|
const [viewMode, setViewMode] = useState<string>('列表');
|
|
|
|
// 日历数据
|
|
const [calendarData, setCalendarData] = useState<CalendarDay[]>([]);
|
|
const [calendarLoading, setCalendarLoading] = useState(false);
|
|
|
|
// 弹窗
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Schedule | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
// ---- 列表数据获取 ----
|
|
const fetchSchedules = useCallback(async (p = page, ps = pageSize) => {
|
|
if (!selectedDoctorId) {
|
|
setData([]);
|
|
setTotal(0);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const result = await appointmentApi.listSchedules({
|
|
page: p,
|
|
page_size: ps,
|
|
doctor_id: selectedDoctorId,
|
|
});
|
|
setData(result.data);
|
|
setTotal(result.total);
|
|
} catch {
|
|
message.error('加载排班列表失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, pageSize, selectedDoctorId]);
|
|
|
|
// ---- 日历数据获取 ----
|
|
const [calendarMonth, setCalendarMonth] = useState<Dayjs>(dayjs());
|
|
|
|
const fetchCalendar = useCallback(async (month?: Dayjs) => {
|
|
if (!selectedDoctorId) {
|
|
setCalendarData([]);
|
|
return;
|
|
}
|
|
const target = month ?? calendarMonth;
|
|
setCalendarLoading(true);
|
|
try {
|
|
const result = await appointmentApi.calendar({
|
|
start_date: target.startOf('month').format('YYYY-MM-DD'),
|
|
end_date: target.endOf('month').format('YYYY-MM-DD'),
|
|
doctor_id: selectedDoctorId,
|
|
});
|
|
setCalendarData(result);
|
|
} catch {
|
|
message.error('加载日历数据失败');
|
|
} finally {
|
|
setCalendarLoading(false);
|
|
}
|
|
}, [selectedDoctorId, calendarMonth]);
|
|
|
|
// 切换医护或视图模式时加载数据
|
|
useEffect(() => {
|
|
if (viewMode === '列表') {
|
|
fetchSchedules();
|
|
} else {
|
|
fetchCalendar();
|
|
}
|
|
}, [fetchSchedules, fetchCalendar, viewMode]);
|
|
|
|
// 切换医护时重置页码
|
|
const handleDoctorChange = (val: string) => {
|
|
setSelectedDoctorId(val || undefined);
|
|
setPage(1);
|
|
};
|
|
|
|
// ---- 新建 / 编辑排班 ----
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({ period_type: 'am', max_appointments: 10 });
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = (record: Schedule) => {
|
|
setEditing(record);
|
|
form.setFieldsValue({
|
|
schedule_date: dayjs(record.schedule_date),
|
|
period_type: record.period_type,
|
|
start_time: dayjs(record.start_time, 'HH:mm'),
|
|
end_time: dayjs(record.end_time, 'HH:mm'),
|
|
max_appointments: record.max_appointments,
|
|
status: record.status,
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleSubmit = async (values: {
|
|
schedule_date: Dayjs;
|
|
period_type: string;
|
|
start_time: Dayjs;
|
|
end_time: Dayjs;
|
|
max_appointments: number;
|
|
status?: string;
|
|
}) => {
|
|
if (!selectedDoctorId) {
|
|
message.warning('请先选择医护');
|
|
return;
|
|
}
|
|
try {
|
|
if (editing) {
|
|
const req: UpdateScheduleReq & { version: number } = {
|
|
start_time: values.start_time.format('HH:mm'),
|
|
end_time: values.end_time.format('HH:mm'),
|
|
max_appointments: values.max_appointments,
|
|
status: values.status,
|
|
version: editing.version,
|
|
};
|
|
await appointmentApi.updateSchedule(editing.id, req);
|
|
message.success('排班更新成功');
|
|
} else {
|
|
const req: CreateScheduleReq = {
|
|
doctor_id: selectedDoctorId,
|
|
schedule_date: values.schedule_date.format('YYYY-MM-DD'),
|
|
period_type: values.period_type,
|
|
start_time: values.start_time.format('HH:mm'),
|
|
end_time: values.end_time.format('HH:mm'),
|
|
max_appointments: values.max_appointments,
|
|
};
|
|
await appointmentApi.createSchedule(req);
|
|
message.success('排班创建成功');
|
|
}
|
|
setModalOpen(false);
|
|
form.resetFields();
|
|
if (viewMode === '列表') {
|
|
fetchSchedules(page, pageSize);
|
|
} else {
|
|
fetchCalendar();
|
|
}
|
|
} catch {
|
|
message.error(editing ? '更新排班失败' : '创建排班失败');
|
|
}
|
|
};
|
|
|
|
// ---- 列定义 ----
|
|
const columns = [
|
|
{
|
|
title: '日期',
|
|
dataIndex: 'schedule_date',
|
|
key: 'schedule_date',
|
|
width: 120,
|
|
render: (val: string) => val || '-',
|
|
},
|
|
{
|
|
title: '时段',
|
|
dataIndex: 'period_type',
|
|
key: 'period_type',
|
|
width: 80,
|
|
render: (val: string) => PERIOD_LABEL[val] || val,
|
|
},
|
|
{
|
|
title: '开始时间',
|
|
dataIndex: 'start_time',
|
|
key: 'start_time',
|
|
width: 100,
|
|
render: (val: string) => val || '-',
|
|
},
|
|
{
|
|
title: '结束时间',
|
|
dataIndex: 'end_time',
|
|
key: 'end_time',
|
|
width: 100,
|
|
render: (val: string) => val || '-',
|
|
},
|
|
{
|
|
title: '已约/上限',
|
|
key: 'appointment_ratio',
|
|
width: 110,
|
|
render: (_: unknown, record: Schedule) => {
|
|
const ratio = record.max_appointments > 0
|
|
? record.current_appointments / record.max_appointments
|
|
: 0;
|
|
const color = ratio >= 1 ? '#ff4d4f' : ratio >= 0.8 ? '#faad14' : '#52c41a';
|
|
return (
|
|
<span style={{ color }}>
|
|
{record.current_appointments}/{record.max_appointments}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 90,
|
|
render: (val: string) => <StatusTag status={val} />,
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'action',
|
|
width: 120,
|
|
render: (_: unknown, record: Schedule) => (
|
|
<AuthButton code="health.doctor.manage">
|
|
<Space size="small">
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => openEdit(record)}
|
|
>
|
|
编辑
|
|
</Button>
|
|
</Space>
|
|
</AuthButton>
|
|
),
|
|
},
|
|
];
|
|
|
|
// ---- 将日历数据转换为 CalendarView 所需格式 ----
|
|
const calendarScheduleMap: Record<string, ScheduleItem[]> = {};
|
|
for (const day of calendarData) {
|
|
calendarScheduleMap[day.date] = day.schedules.map((s) => ({
|
|
id: s.id,
|
|
start_time: s.start_time,
|
|
end_time: s.end_time,
|
|
current_appointments: s.current_appointments,
|
|
max_appointments: s.max_appointments,
|
|
status: s.status,
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
{/* 顶部操作栏 */}
|
|
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
|
<Col>
|
|
<Space>
|
|
<span style={{ fontWeight: 500 }}>选择医护:</span>
|
|
<DoctorSelect
|
|
value={selectedDoctorId}
|
|
onChange={handleDoctorChange}
|
|
placeholder="搜索选择医护"
|
|
/>
|
|
{selectedDoctorId && (
|
|
<Segmented
|
|
value={viewMode}
|
|
onChange={(val) => setViewMode(val as string)}
|
|
options={['列表', '日历']}
|
|
/>
|
|
)}
|
|
</Space>
|
|
</Col>
|
|
<Col flex="auto" />
|
|
<Col>
|
|
{selectedDoctorId && (
|
|
<AuthButton code="health.doctor.manage">
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
|
新建排班
|
|
</Button>
|
|
</AuthButton>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* 内容区 */}
|
|
{!selectedDoctorId ? (
|
|
<Empty description="请先选择医护以查看排班" />
|
|
) : viewMode === '列表' ? (
|
|
<Table
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={data}
|
|
loading={loading}
|
|
scroll={{ x: 750 }}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
onChange: (p, ps) => {
|
|
setPage(p);
|
|
setPageSize(ps);
|
|
},
|
|
}}
|
|
/>
|
|
) : (
|
|
<Spin spinning={calendarLoading}>
|
|
<div style={{ marginTop: 16 }}>
|
|
<CalendarView
|
|
schedules={calendarScheduleMap}
|
|
onPanelChange={(date) => {
|
|
setCalendarMonth(date);
|
|
fetchCalendar(date);
|
|
}}
|
|
/>
|
|
</div>
|
|
</Spin>
|
|
)}
|
|
|
|
{/* 新建 / 编辑排班弹窗 */}
|
|
<Modal
|
|
title={editing ? '编辑排班' : '新建排班'}
|
|
open={modalOpen}
|
|
onCancel={() => {
|
|
setModalOpen(false);
|
|
form.resetFields();
|
|
}}
|
|
onOk={() => form.submit()}
|
|
destroyOnClose
|
|
width={520}
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="schedule_date"
|
|
label="排班日期"
|
|
rules={[{ required: true, message: '请选择日期' }]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} disabled={!!editing} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item name="period_type" label="时段" rules={[{ required: true }]}>
|
|
<Select options={PERIOD_OPTIONS} disabled={!!editing} />
|
|
</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>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="max_appointments"
|
|
label="最大预约数"
|
|
rules={[{ required: true, message: '请输入最大预约数' }]}
|
|
>
|
|
<InputNumber min={1} max={200} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
{editing && (
|
|
<Col span={12}>
|
|
<Form.Item name="status" label="状态">
|
|
<Select options={SCHEDULE_STATUS_OPTIONS} />
|
|
</Form.Item>
|
|
</Col>
|
|
)}
|
|
</Row>
|
|
</Form>
|
|
</Modal>
|
|
</Card>
|
|
);
|
|
}
|