feat(web): 班次管理 Web UI — Phase 2a-2
新增班次管理前端页面,接入后端 12 条孤立路由: - API 模块: shifts.ts(班次 CRUD + 患者分配 + 批量分配 + 交接日志) - 列表页: ShiftList.tsx(日期/班次/状态筛选 + 统计概览) - 详情页: ShiftDetail.tsx(班次信息 + 患者分配 Tab + 交接记录 Tab) - 路由注册: /health/shifts + /health/shifts/:id 权限: health.shifts.list / health.shifts.manage
This commit is contained in:
306
apps/web/src/pages/health/ShiftDetail.tsx
Normal file
306
apps/web/src/pages/health/ShiftDetail.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user