新增班次管理前端页面,接入后端 12 条孤立路由: - API 模块: shifts.ts(班次 CRUD + 患者分配 + 批量分配 + 交接日志) - 列表页: ShiftList.tsx(日期/班次/状态筛选 + 统计概览) - 详情页: ShiftDetail.tsx(班次信息 + 患者分配 Tab + 交接记录 Tab) - 路由注册: /health/shifts + /health/shifts/:id 权限: health.shifts.list / health.shifts.manage
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
Button, Descriptions, Form, Input, message, Modal, Popconfirm,
|
|
Result, Select, Space, Spin, Table, Tabs, Tag,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import dayjs from 'dayjs';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
|
|
import {
|
|
shiftApi,
|
|
type Shift,
|
|
type PatientAssignment,
|
|
type HandoffLog,
|
|
type CreatePatientAssignmentReq,
|
|
type CreateHandoffReq,
|
|
PERIOD_LABEL,
|
|
SHIFT_STATUS_LABEL,
|
|
SHIFT_STATUS_COLOR,
|
|
CARE_LEVEL_OPTIONS,
|
|
CARE_LEVEL_LABEL,
|
|
CARE_LEVEL_COLOR,
|
|
} from '../../api/health/shifts';
|
|
import { PageContainer } from '../../components/PageContainer';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
|
|
export default function ShiftDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { hasPermission } = usePermission('health.shifts.manage');
|
|
|
|
const [shift, setShift] = useState<Shift | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Assignments
|
|
const [assignments, setAssignments] = useState<PatientAssignment[]>([]);
|
|
const [assignLoading, setAssignLoading] = useState(false);
|
|
const [assignModalOpen, setAssignModalOpen] = useState(false);
|
|
const [editAssignment, setEditAssignment] = useState<PatientAssignment | null>(null);
|
|
const [assignForm] = Form.useForm();
|
|
|
|
// Handoff logs
|
|
const [handoffs, setHandoffs] = useState<HandoffLog[]>([]);
|
|
const [handoffLoading, setHandoffLoading] = useState(false);
|
|
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
|
|
const [handoffForm] = Form.useForm();
|
|
|
|
const shiftId = id!;
|
|
|
|
const fetchShift = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await shiftApi.get(shiftId);
|
|
setShift(data);
|
|
} catch {
|
|
message.error('加载班次详情失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [shiftId]);
|
|
|
|
const fetchAssignments = useCallback(async () => {
|
|
setAssignLoading(true);
|
|
try {
|
|
const resp = await shiftApi.listAssignments(shiftId, { page: 1, page_size: 200 });
|
|
setAssignments(resp.data);
|
|
} catch {
|
|
message.error('加载患者分配失败');
|
|
} finally {
|
|
setAssignLoading(false);
|
|
}
|
|
}, [shiftId]);
|
|
|
|
const fetchHandoffs = useCallback(async () => {
|
|
setHandoffLoading(true);
|
|
try {
|
|
const resp = await shiftApi.listHandoffs({ from_shift_id: shiftId });
|
|
setHandoffs(resp.data);
|
|
} catch {
|
|
message.error('加载交接记录失败');
|
|
} finally {
|
|
setHandoffLoading(false);
|
|
}
|
|
}, [shiftId]);
|
|
|
|
useEffect(() => {
|
|
fetchShift();
|
|
fetchAssignments();
|
|
fetchHandoffs();
|
|
}, [fetchShift, fetchAssignments, fetchHandoffs]);
|
|
|
|
// --- Assignment CRUD ---
|
|
|
|
const handleAddAssignment = () => {
|
|
setEditAssignment(null);
|
|
assignForm.resetFields();
|
|
setAssignModalOpen(true);
|
|
};
|
|
|
|
const handleEditAssignment = (record: PatientAssignment) => {
|
|
setEditAssignment(record);
|
|
assignForm.setFieldsValue({
|
|
patient_id: record.patient_id,
|
|
care_level: record.care_level,
|
|
notes: record.notes,
|
|
});
|
|
setAssignModalOpen(true);
|
|
};
|
|
|
|
const handleSubmitAssignment = async () => {
|
|
try {
|
|
const values = await assignForm.validateFields();
|
|
if (editAssignment) {
|
|
await shiftApi.updateAssignment(shiftId, editAssignment.id, {
|
|
care_level: values.care_level,
|
|
notes: values.notes,
|
|
version: editAssignment.version,
|
|
});
|
|
message.success('分配已更新');
|
|
} else {
|
|
await shiftApi.createAssignment(shiftId, values as CreatePatientAssignmentReq);
|
|
message.success('患者已分配');
|
|
}
|
|
setAssignModalOpen(false);
|
|
fetchAssignments();
|
|
fetchShift();
|
|
} catch {
|
|
// validation
|
|
}
|
|
};
|
|
|
|
const handleRemoveAssignment = async (record: PatientAssignment) => {
|
|
try {
|
|
await shiftApi.deleteAssignment(shiftId, record.id, record.version);
|
|
message.success('已移除分配');
|
|
fetchAssignments();
|
|
fetchShift();
|
|
} catch {
|
|
message.error('移除失败');
|
|
}
|
|
};
|
|
|
|
// --- Handoff ---
|
|
|
|
const handleCreateHandoff = () => {
|
|
handoffForm.resetFields();
|
|
handoffForm.setFieldsValue({ from_shift_id: shiftId });
|
|
setHandoffModalOpen(true);
|
|
};
|
|
|
|
const handleSubmitHandoff = async () => {
|
|
try {
|
|
const values = await handoffForm.validateFields();
|
|
await shiftApi.createHandoff(values as CreateHandoffReq);
|
|
message.success('交接记录已创建');
|
|
setHandoffModalOpen(false);
|
|
fetchHandoffs();
|
|
} catch {
|
|
// validation
|
|
}
|
|
};
|
|
|
|
// --- Columns ---
|
|
|
|
const assignColumns: ColumnsType<PatientAssignment> = [
|
|
{ title: '患者 ID', dataIndex: 'patient_id', width: 280, ellipsis: true },
|
|
{
|
|
title: '护理等级',
|
|
dataIndex: 'care_level',
|
|
width: 120,
|
|
render: (v: string) => <Tag color={CARE_LEVEL_COLOR[v] ?? 'default'}>{CARE_LEVEL_LABEL[v] ?? v}</Tag>,
|
|
},
|
|
{ title: '备注', dataIndex: 'notes', width: 200, ellipsis: true, render: (v: string) => v ?? '-' },
|
|
{
|
|
title: '操作', width: 140, render: (_, record) => hasPermission ? (
|
|
<Space>
|
|
<Button size="small" onClick={() => handleEditAssignment(record)}>编辑</Button>
|
|
<Popconfirm title="确定移除此患者?" onConfirm={() => handleRemoveAssignment(record)}>
|
|
<Button size="small" danger>移除</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
const handoffColumns: ColumnsType<HandoffLog> = [
|
|
{ title: '患者 ID', dataIndex: 'patient_id', width: 280, ellipsis: true },
|
|
{ title: '目标班次', dataIndex: 'to_shift_id', width: 280, ellipsis: true },
|
|
{ title: '交接备注', dataIndex: 'notes', width: 200, ellipsis: true, render: (v: string) => v ?? '-' },
|
|
{
|
|
title: '交接时间',
|
|
dataIndex: 'created_at',
|
|
width: 170,
|
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
|
},
|
|
];
|
|
|
|
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
|
if (!shift) return <Result status="404" title="班次不存在" />;
|
|
if (!hasPermission) return <Result status="403" title="权限不足" />;
|
|
|
|
return (
|
|
<PageContainer
|
|
title={`${dayjs(shift.shift_date).format('YYYY-MM-DD')} ${PERIOD_LABEL[shift.period] ?? shift.period}`}
|
|
onBack={() => navigate('/health/shifts')}
|
|
>
|
|
<Descriptions bordered size="small" column={3} style={{ marginBottom: 24 }}>
|
|
<Descriptions.Item label="状态">
|
|
<Tag color={SHIFT_STATUS_COLOR[shift.status] ?? 'default'}>{SHIFT_STATUS_LABEL[shift.status] ?? shift.status}</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="患者数">{shift.patient_count ?? assignments.length}</Descriptions.Item>
|
|
<Descriptions.Item label="责任护士">{shift.nurse_id ?? '-'}</Descriptions.Item>
|
|
{shift.notes && <Descriptions.Item label="备注" span={3}>{shift.notes}</Descriptions.Item>}
|
|
</Descriptions>
|
|
|
|
<Tabs
|
|
defaultActiveKey="assignments"
|
|
items={[
|
|
{
|
|
key: 'assignments',
|
|
label: `患者分配 (${assignments.length})`,
|
|
children: (
|
|
<>
|
|
<Space style={{ marginBottom: 12 }}>
|
|
<Button type="primary" size="small" onClick={handleAddAssignment}>分配患者</Button>
|
|
</Space>
|
|
<Table<PatientAssignment>
|
|
rowKey="id"
|
|
columns={assignColumns}
|
|
dataSource={assignments}
|
|
loading={assignLoading}
|
|
pagination={false}
|
|
size="small"
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: 'handoffs',
|
|
label: `交接记录 (${handoffs.length})`,
|
|
children: (
|
|
<>
|
|
<Button type="primary" size="small" style={{ marginBottom: 12 }} onClick={handleCreateHandoff}>
|
|
新建交接
|
|
</Button>
|
|
<Table<HandoffLog>
|
|
rowKey="id"
|
|
columns={handoffColumns}
|
|
dataSource={handoffs}
|
|
loading={handoffLoading}
|
|
pagination={false}
|
|
size="small"
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Assignment Modal */}
|
|
<Modal
|
|
title={editAssignment ? '编辑分配' : '分配患者'}
|
|
open={assignModalOpen}
|
|
onOk={handleSubmitAssignment}
|
|
onCancel={() => setAssignModalOpen(false)}
|
|
width={480}
|
|
>
|
|
<Form form={assignForm} layout="vertical">
|
|
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true, message: '请输入患者 ID' }]}>
|
|
<Input placeholder="患者 UUID" disabled={!!editAssignment} />
|
|
</Form.Item>
|
|
<Form.Item name="care_level" label="护理等级">
|
|
<Select options={CARE_LEVEL_OPTIONS} placeholder="选择等级" allowClear />
|
|
</Form.Item>
|
|
<Form.Item name="notes" label="备注">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Handoff Modal */}
|
|
<Modal
|
|
title="新建交接记录"
|
|
open={handoffModalOpen}
|
|
onOk={handleSubmitHandoff}
|
|
onCancel={() => setHandoffModalOpen(false)}
|
|
width={520}
|
|
>
|
|
<Form form={handoffForm} layout="vertical">
|
|
<Form.Item name="from_shift_id" label="来源班次" rules={[{ required: true }]}>
|
|
<Input disabled />
|
|
</Form.Item>
|
|
<Form.Item name="to_shift_id" label="目标班次 ID" rules={[{ required: true, message: '请输入目标班次 ID' }]}>
|
|
<Input placeholder="目标班次 UUID" />
|
|
</Form.Item>
|
|
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true, message: '请输入患者 ID' }]}>
|
|
<Input placeholder="患者 UUID" />
|
|
</Form.Item>
|
|
<Form.Item name="notes" label="交接备注">
|
|
<Input.TextArea rows={3} placeholder="需要交代的特殊事项" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|