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>
|
||||
);
|
||||
}
|
||||
275
apps/web/src/pages/health/ShiftList.tsx
Normal file
275
apps/web/src/pages/health/ShiftList.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Badge, Button, DatePicker, Form, Input, message, Modal, Popconfirm,
|
||||
Result, Select, Space, Table, Tag,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
shiftApi,
|
||||
type Shift,
|
||||
type CreateShiftReq,
|
||||
type UpdateShiftReq,
|
||||
PERIOD_OPTIONS,
|
||||
SHIFT_STATUS_OPTIONS,
|
||||
PERIOD_LABEL,
|
||||
SHIFT_STATUS_LABEL,
|
||||
SHIFT_STATUS_COLOR,
|
||||
} from '../../api/health/shifts';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
|
||||
interface FilterValues {
|
||||
shift_date?: string;
|
||||
period?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export default function ShiftList() {
|
||||
const { hasPermission } = usePermission('health.shifts.manage');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data, setData] = useState<Shift[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<Shift | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const fetchData = useCallback(async (p: number, f: FilterValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await shiftApi.list({
|
||||
page: p,
|
||||
page_size: pageSize,
|
||||
shift_date: f.shift_date || undefined,
|
||||
period: f.period || undefined,
|
||||
status: f.status || undefined,
|
||||
});
|
||||
setData(resp.data);
|
||||
setTotal(resp.total);
|
||||
setPage(p);
|
||||
} catch {
|
||||
message.error('加载班次列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1, filters);
|
||||
}, [fetchData, filters]);
|
||||
|
||||
const handleFilterChange = (key: string, value: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: Shift) => {
|
||||
setEditRecord(record);
|
||||
form.setFieldsValue({
|
||||
shift_date: record.shift_date ? dayjs(record.shift_date) : undefined,
|
||||
period: record.period,
|
||||
nurse_id: record.nurse_id,
|
||||
notes: record.notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const req = {
|
||||
shift_date: values.shift_date?.format('YYYY-MM-DD'),
|
||||
period: values.period,
|
||||
nurse_id: values.nurse_id || undefined,
|
||||
notes: values.notes,
|
||||
...(editRecord ? { version: editRecord.version } : {}),
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
if (editRecord) {
|
||||
await shiftApi.update(editRecord.id, req as UpdateShiftReq & { version: number });
|
||||
message.success('班次已更新');
|
||||
} else {
|
||||
await shiftApi.create(req as CreateShiftReq);
|
||||
message.success('班次已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData(page, filters);
|
||||
} catch {
|
||||
// validation
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: Shift) => {
|
||||
try {
|
||||
await shiftApi.delete(record.id, record.version);
|
||||
message.success('班次已删除');
|
||||
fetchData(page, filters);
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Shift> = useMemo(() => [
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'shift_date',
|
||||
width: 120,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD'),
|
||||
},
|
||||
{
|
||||
title: '班次',
|
||||
dataIndex: 'period',
|
||||
width: 100,
|
||||
render: (v: string) => <Tag>{PERIOD_LABEL[v] ?? v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => (
|
||||
<Tag color={SHIFT_STATUS_COLOR[v] ?? 'default'}>{SHIFT_STATUS_LABEL[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '患者数',
|
||||
dataIndex: 'patient_count',
|
||||
width: 80,
|
||||
render: (v: number) => v ?? 0,
|
||||
},
|
||||
{
|
||||
title: '危重',
|
||||
dataIndex: 'critical_count',
|
||||
width: 70,
|
||||
render: (v: number) => v ? <Tag color="red">{v}</Tag> : 0,
|
||||
},
|
||||
{
|
||||
title: '需关注',
|
||||
dataIndex: 'attention_count',
|
||||
width: 70,
|
||||
render: (v: number) => v ? <Tag color="orange">{v}</Tag> : 0,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" onClick={() => navigate(`/health/shifts/${record.id}`)}>详情</Button>
|
||||
<Button size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除此班次?" onConfirm={() => handleDelete(record)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [navigate, page, filters]);
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有管理班次的权限" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="班次管理"
|
||||
actions={<Button type="primary" onClick={handleCreate}>新建班次</Button>}
|
||||
>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker
|
||||
placeholder="选择日期"
|
||||
value={filters.shift_date ? dayjs(filters.shift_date) : undefined}
|
||||
onChange={(_, ds) => handleFilterChange('shift_date', ds as string || undefined)}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="班次"
|
||||
options={PERIOD_OPTIONS}
|
||||
value={filters.period}
|
||||
onChange={(v) => handleFilterChange('period', v)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
options={SHIFT_STATUS_OPTIONS}
|
||||
value={filters.status}
|
||||
onChange={(v) => handleFilterChange('status', v)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Button onClick={handleResetFilters}>重置</Button>
|
||||
</Space>
|
||||
|
||||
<Table<Shift>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p) => fetchData(p, filters),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRecord ? '编辑班次' : '新建班次'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={submitting}
|
||||
width={480}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="shift_date" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="period" label="班次" rules={[{ required: true, message: '请选择班次' }]}>
|
||||
<Select options={PERIOD_OPTIONS} placeholder="选择班次" />
|
||||
</Form.Item>
|
||||
<Form.Item name="nurse_id" label="责任护士 ID">
|
||||
<Input placeholder="护士 UUID(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user