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:
@@ -55,6 +55,8 @@ const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
|
|||||||
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
|
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
|
||||||
const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
|
const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
|
||||||
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
|
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
|
||||||
|
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
||||||
|
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
||||||
|
|
||||||
// 内容管理
|
// 内容管理
|
||||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||||
@@ -275,6 +277,8 @@ export default function App() {
|
|||||||
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
|
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
|
||||||
<Route path="/health/care-plans" element={<CarePlanList />} />
|
<Route path="/health/care-plans" element={<CarePlanList />} />
|
||||||
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
|
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
|
||||||
|
<Route path="/health/shifts" element={<ShiftList />} />
|
||||||
|
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
||||||
{/* 内容管理 */}
|
{/* 内容管理 */}
|
||||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||||
|
|||||||
247
apps/web/src/api/health/shifts.ts
Normal file
247
apps/web/src/api/health/shifts.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import client from '../client';
|
||||||
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface Shift {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
shift_date: string;
|
||||||
|
period: string;
|
||||||
|
nurse_id?: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
|
patient_count?: number;
|
||||||
|
critical_count?: number;
|
||||||
|
attention_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientAssignment {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
shift_id: string;
|
||||||
|
patient_id: string;
|
||||||
|
care_level: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
|
patient_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandoffLog {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
from_shift_id: string;
|
||||||
|
to_shift_id: string;
|
||||||
|
patient_id: string;
|
||||||
|
notes?: string;
|
||||||
|
pending_items?: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
|
patient_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShiftReq {
|
||||||
|
shift_date: string;
|
||||||
|
period: string;
|
||||||
|
nurse_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateShiftReq {
|
||||||
|
shift_date?: string;
|
||||||
|
period?: string;
|
||||||
|
nurse_id?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListShiftsParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
shift_date?: string;
|
||||||
|
period?: string;
|
||||||
|
nurse_id?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePatientAssignmentReq {
|
||||||
|
patient_id: string;
|
||||||
|
care_level?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchAssignReq {
|
||||||
|
patient_ids: string[];
|
||||||
|
care_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePatientAssignmentReq {
|
||||||
|
care_level?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHandoffReq {
|
||||||
|
from_shift_id: string;
|
||||||
|
to_shift_id: string;
|
||||||
|
patient_id: string;
|
||||||
|
notes?: string;
|
||||||
|
pending_items?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListHandoffParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
from_shift_id?: string;
|
||||||
|
to_shift_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
export const PERIOD_OPTIONS = [
|
||||||
|
{ label: '上午班', value: 'morning' },
|
||||||
|
{ label: '下午班', value: 'afternoon' },
|
||||||
|
{ label: '晚班', value: 'evening' },
|
||||||
|
{ label: '夜班', value: 'night' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SHIFT_STATUS_OPTIONS = [
|
||||||
|
{ label: '待开始', value: 'scheduled' },
|
||||||
|
{ label: '进行中', value: 'in_progress' },
|
||||||
|
{ label: '已完成', value: 'completed' },
|
||||||
|
{ label: '已取消', value: 'cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CARE_LEVEL_OPTIONS = [
|
||||||
|
{ label: '稳定', value: 'stable' },
|
||||||
|
{ label: '需关注', value: 'attention' },
|
||||||
|
{ label: '危重', value: 'critical' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PERIOD_LABEL: Record<string, string> = Object.fromEntries(
|
||||||
|
PERIOD_OPTIONS.map((o) => [o.value, o.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SHIFT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
|
||||||
|
SHIFT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SHIFT_STATUS_COLOR: Record<string, string> = {
|
||||||
|
scheduled: 'default',
|
||||||
|
in_progress: 'processing',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CARE_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
|
||||||
|
CARE_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CARE_LEVEL_COLOR: Record<string, string> = {
|
||||||
|
stable: 'green',
|
||||||
|
attention: 'orange',
|
||||||
|
critical: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
|
||||||
|
export const shiftApi = {
|
||||||
|
// --- Shifts ---
|
||||||
|
|
||||||
|
list: async (params: ListShiftsParams) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<Shift>;
|
||||||
|
}>('/health/shifts', { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (shiftId: string) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: Shift;
|
||||||
|
}>(`/health/shifts/${shiftId}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req: CreateShiftReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Shift;
|
||||||
|
}>('/health/shifts', req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (shiftId: string, req: UpdateShiftReq & { version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: Shift;
|
||||||
|
}>(`/health/shifts/${shiftId}`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (shiftId: string, version: number) => {
|
||||||
|
await client.delete(`/health/shifts/${shiftId}`, { data: { version } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Assignments ---
|
||||||
|
|
||||||
|
listAssignments: async (shiftId: string, params?: { page?: number; page_size?: number }) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<PatientAssignment>;
|
||||||
|
}>(`/health/shifts/${shiftId}/assignments`, { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createAssignment: async (shiftId: string, req: CreatePatientAssignmentReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: PatientAssignment;
|
||||||
|
}>(`/health/shifts/${shiftId}/assignments`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
batchAssign: async (shiftId: string, req: BatchAssignReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: PatientAssignment[];
|
||||||
|
}>(`/health/shifts/${shiftId}/assignments/batch`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAssignment: async (shiftId: string, assignmentId: string, req: UpdatePatientAssignmentReq & { version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: PatientAssignment;
|
||||||
|
}>(`/health/shifts/${shiftId}/assignments/${assignmentId}`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAssignment: async (shiftId: string, assignmentId: string, version: number) => {
|
||||||
|
await client.delete(`/health/shifts/${shiftId}/assignments/${assignmentId}`, { data: { version } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Handoff Logs ---
|
||||||
|
|
||||||
|
listHandoffs: async (params?: ListHandoffParams) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<HandoffLog>;
|
||||||
|
}>('/health/handoff-logs', { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createHandoff: async (req: CreateHandoffReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: HandoffLog;
|
||||||
|
}>('/health/handoff-logs', req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -113,6 +113,8 @@ const routeTitleFallback: Record<string, string> = {
|
|||||||
'/health/follow-up-templates': '随访模板管理',
|
'/health/follow-up-templates': '随访模板管理',
|
||||||
'/health/care-plans': '护理计划',
|
'/health/care-plans': '护理计划',
|
||||||
'/health/care-plans/:id': '护理计划详情',
|
'/health/care-plans/:id': '护理计划详情',
|
||||||
|
'/health/shifts': '班次管理',
|
||||||
|
'/health/shifts/:id': '班次详情',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
|||||||
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