Files
hms/apps/web/src/pages/health/ShiftDetail.tsx
iven 68ced2bae9
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
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
2026-05-04 23:36:15 +08:00

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>
);
}