diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7367c6f..7262bba 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -55,6 +55,8 @@ const ActionInbox = lazy(() => import('./pages/health/ActionInbox')); const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList')); const CarePlanList = lazy(() => import('./pages/health/CarePlanList')); 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')); @@ -275,6 +277,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/shifts.ts b/apps/web/src/api/health/shifts.ts new file mode 100644 index 0000000..05f2fbe --- /dev/null +++ b/apps/web/src/api/health/shifts.ts @@ -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; + 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; +} + +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 = Object.fromEntries( + PERIOD_OPTIONS.map((o) => [o.value, o.label]), +); + +export const SHIFT_STATUS_LABEL: Record = Object.fromEntries( + SHIFT_STATUS_OPTIONS.map((o) => [o.value, o.label]), +); + +export const SHIFT_STATUS_COLOR: Record = { + scheduled: 'default', + in_progress: 'processing', + completed: 'success', + cancelled: 'error', +}; + +export const CARE_LEVEL_LABEL: Record = Object.fromEntries( + CARE_LEVEL_OPTIONS.map((o) => [o.value, o.label]), +); + +export const CARE_LEVEL_COLOR: Record = { + stable: 'green', + attention: 'orange', + critical: 'red', +}; + +// --- API --- + +export const shiftApi = { + // --- Shifts --- + + list: async (params: ListShiftsParams) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/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; + }>(`/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; + }>('/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; + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index cca3029..e51ef04 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -113,6 +113,8 @@ const routeTitleFallback: Record = { '/health/follow-up-templates': '随访模板管理', '/health/care-plans': '护理计划', '/health/care-plans/:id': '护理计划详情', + '/health/shifts': '班次管理', + '/health/shifts/:id': '班次详情', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/ShiftDetail.tsx b/apps/web/src/pages/health/ShiftDetail.tsx new file mode 100644 index 0000000..d0dda5e --- /dev/null +++ b/apps/web/src/pages/health/ShiftDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + + // Assignments + const [assignments, setAssignments] = useState([]); + const [assignLoading, setAssignLoading] = useState(false); + const [assignModalOpen, setAssignModalOpen] = useState(false); + const [editAssignment, setEditAssignment] = useState(null); + const [assignForm] = Form.useForm(); + + // Handoff logs + const [handoffs, setHandoffs] = useState([]); + 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 = [ + { title: '患者 ID', dataIndex: 'patient_id', width: 280, ellipsis: true }, + { + title: '护理等级', + dataIndex: 'care_level', + width: 120, + render: (v: string) => {CARE_LEVEL_LABEL[v] ?? v}, + }, + { title: '备注', dataIndex: 'notes', width: 200, ellipsis: true, render: (v: string) => v ?? '-' }, + { + title: '操作', width: 140, render: (_, record) => hasPermission ? ( + + + handleRemoveAssignment(record)}> + + + + ) : null, + }, + ]; + + const handoffColumns: ColumnsType = [ + { 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 ; + if (!shift) return ; + if (!hasPermission) return ; + + return ( + navigate('/health/shifts')} + > + + + {SHIFT_STATUS_LABEL[shift.status] ?? shift.status} + + {shift.patient_count ?? assignments.length} + {shift.nurse_id ?? '-'} + {shift.notes && {shift.notes}} + + + + + + + + rowKey="id" + columns={assignColumns} + dataSource={assignments} + loading={assignLoading} + pagination={false} + size="small" + /> + + ), + }, + { + key: 'handoffs', + label: `交接记录 (${handoffs.length})`, + children: ( + <> + + + rowKey="id" + columns={handoffColumns} + dataSource={handoffs} + loading={handoffLoading} + pagination={false} + size="small" + /> + + ), + }, + ]} + /> + + {/* Assignment Modal */} + setAssignModalOpen(false)} + width={480} + > +
+ + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/health/ShiftList.tsx b/apps/web/src/pages/health/ShiftList.tsx new file mode 100644 index 0000000..d3e9766 --- /dev/null +++ b/apps/web/src/pages/health/ShiftList.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({}); + + const [modalOpen, setModalOpen] = useState(false); + const [editRecord, setEditRecord] = useState(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 = useMemo(() => [ + { + title: '日期', + dataIndex: 'shift_date', + width: 120, + render: (v: string) => dayjs(v).format('YYYY-MM-DD'), + }, + { + title: '班次', + dataIndex: 'period', + width: 100, + render: (v: string) => {PERIOD_LABEL[v] ?? v}, + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => ( + {SHIFT_STATUS_LABEL[v] ?? v} + ), + }, + { + title: '患者数', + dataIndex: 'patient_count', + width: 80, + render: (v: number) => v ?? 0, + }, + { + title: '危重', + dataIndex: 'critical_count', + width: 70, + render: (v: number) => v ? {v} : 0, + }, + { + title: '需关注', + dataIndex: 'attention_count', + width: 70, + render: (v: number) => v ? {v} : 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) => ( + + + + handleDelete(record)}> + + + + ), + }, + ], [navigate, page, filters]); + + if (!hasPermission) { + return ; + } + + return ( + 新建班次} + > + + handleFilterChange('shift_date', ds as string || undefined)} + style={{ width: 150 }} + /> + handleFilterChange('status', v)} + style={{ width: 120 }} + /> + + + + + rowKey="id" + columns={columns} + dataSource={data} + loading={loading} + pagination={{ + current: page, + pageSize, + total, + showTotal: (t) => `共 ${t} 条`, + onChange: (p) => fetchData(p, filters), + }} + /> + + setModalOpen(false)} + confirmLoading={submitting} + width={480} + > +
+ + + + + + + + + +
+
+
+ ); +}