diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5d8d79b..7367c6f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -53,6 +53,8 @@ const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList')); const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList')); 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 ArticleManageList = lazy(() => import('./pages/health/ArticleManageList')); @@ -271,6 +273,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/carePlans.ts b/apps/web/src/api/health/carePlans.ts new file mode 100644 index 0000000..6a8b2c5 --- /dev/null +++ b/apps/web/src/api/health/carePlans.ts @@ -0,0 +1,245 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- + +export interface CarePlan { + id: string; + patient_id: string; + plan_type: string; + status: string; + title: string; + goals?: Record; + start_date?: string; + end_date?: string; + notes?: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface CarePlanItem { + id: string; + plan_id: string; + item_type: string; + title: string; + description?: string; + status: string; + schedule?: string; + sort_order?: number; + created_at: string; + updated_at: string; + version: number; +} + +export interface CarePlanOutcome { + id: string; + plan_id: string; + item_id?: string; + metric: string; + baseline_value: string; + target_value: string; + current_value?: string; + measured_at?: string; + notes?: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateCarePlanReq { + patient_id: string; + plan_type: string; + title: string; + goals?: Record; + start_date?: string; + end_date?: string; + notes?: string; +} + +export interface UpdateCarePlanReq { + plan_type?: string; + title?: string; + status?: string; + goals?: Record; + start_date?: string; + end_date?: string; + notes?: string; +} + +export interface CreateCarePlanItemReq { + item_type: string; + title: string; + description?: string; + schedule?: string; + sort_order?: number; +} + +export interface UpdateCarePlanItemReq { + item_type?: string; + title?: string; + description?: string; + status?: string; + schedule?: string; + sort_order?: number; +} + +export interface CreateCarePlanOutcomeReq { + item_id?: string; + metric: string; + baseline_value: string; + target_value: string; + current_value?: string; + measured_at?: string; + notes?: string; +} + +export interface UpdateCarePlanOutcomeReq { + item_id?: string; + metric?: string; + baseline_value?: string; + target_value?: string; + current_value?: string; + measured_at?: string; + notes?: string; +} + +export interface ListCarePlansParams { + page?: number; + page_size?: number; + patient_id?: string; + plan_type?: string; + status?: string; +} + +// --- Constants --- + +export const PLAN_TYPE_OPTIONS = [ + { label: '血液透析', value: 'hemodialysis' }, + { label: '腹膜透析', value: 'peritoneal' }, + { label: '慢性病管理', value: 'chronic_disease' }, + { label: '康复计划', value: 'rehabilitation' }, +]; + +export const PLAN_STATUS_OPTIONS = [ + { label: '草稿', value: 'draft' }, + { label: '进行中', value: 'active' }, + { label: '已完成', value: 'completed' }, + { label: '已取消', value: 'cancelled' }, +]; + +export const ITEM_TYPE_OPTIONS = [ + { label: '药物干预', value: 'medication' }, + { label: '饮食管理', value: 'diet' }, + { label: '运动计划', value: 'exercise' }, + { label: '监测项目', value: 'monitoring' }, + { label: '教育指导', value: 'education' }, + { label: '其他', value: 'other' }, +]; + +export const PLAN_STATUS_COLOR: Record = { + draft: 'default', + active: 'processing', + completed: 'success', + cancelled: 'error', +}; + +// --- API --- + +export const carePlanApi = { + list: async (params: ListCarePlansParams) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/care-plans', { params }); + return data.data; + }, + + get: async (id: string) => { + const { data } = await client.get<{ + success: boolean; + data: CarePlan; + }>(`/health/care-plans/${id}`); + return data.data; + }, + + create: async (req: CreateCarePlanReq) => { + const { data } = await client.post<{ + success: boolean; + data: CarePlan; + }>('/health/care-plans', req); + return data.data; + }, + + update: async (id: string, req: UpdateCarePlanReq & { version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: CarePlan; + }>(`/health/care-plans/${id}`, req); + return data.data; + }, + + delete: async (id: string, version: number) => { + await client.delete(`/health/care-plans/${id}`, { data: { version } }); + }, + + // --- Items --- + + listItems: async (planId: string, params?: { page?: number; page_size?: number }) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>(`/health/care-plans/${planId}/items`, { params }); + return data.data; + }, + + createItem: async (planId: string, req: CreateCarePlanItemReq) => { + const { data } = await client.post<{ + success: boolean; + data: CarePlanItem; + }>(`/health/care-plans/${planId}/items`, req); + return data.data; + }, + + updateItem: async (planId: string, itemId: string, req: UpdateCarePlanItemReq & { version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: CarePlanItem; + }>(`/health/care-plans/${planId}/items/${itemId}`, req); + return data.data; + }, + + deleteItem: async (planId: string, itemId: string, version: number) => { + await client.delete(`/health/care-plans/${planId}/items/${itemId}`, { data: { version } }); + }, + + // --- Outcomes --- + + listOutcomes: async (planId: string, params?: { page?: number; page_size?: number }) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>(`/health/care-plans/${planId}/outcomes`, { params }); + return data.data; + }, + + createOutcome: async (planId: string, req: CreateCarePlanOutcomeReq) => { + const { data } = await client.post<{ + success: boolean; + data: CarePlanOutcome; + }>(`/health/care-plans/${planId}/outcomes`, req); + return data.data; + }, + + updateOutcome: async (planId: string, outcomeId: string, req: UpdateCarePlanOutcomeReq & { version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: CarePlanOutcome; + }>(`/health/care-plans/${planId}/outcomes/${outcomeId}`, req); + return data.data; + }, + + deleteOutcome: async (planId: string, outcomeId: string, version: number) => { + await client.delete(`/health/care-plans/${planId}/outcomes/${outcomeId}`, { data: { version } }); + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index f4b7090..cca3029 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -111,6 +111,8 @@ const routeTitleFallback: Record = { '/health/devices': '设备管理', '/health/dialysis': '透析管理', '/health/follow-up-templates': '随访模板管理', + '/health/care-plans': '护理计划', + '/health/care-plans/:id': '护理计划详情', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/CarePlanDetail.tsx b/apps/web/src/pages/health/CarePlanDetail.tsx new file mode 100644 index 0000000..4f1fb92 --- /dev/null +++ b/apps/web/src/pages/health/CarePlanDetail.tsx @@ -0,0 +1,361 @@ +import { useState, useCallback, useEffect } from 'react'; +import { + Button, DatePicker, Descriptions, Form, Input, InputNumber, 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 { + carePlanApi, + type CarePlan, + type CarePlanItem, + type CarePlanOutcome, + type CreateCarePlanItemReq, + type CreateCarePlanOutcomeReq, + PLAN_TYPE_OPTIONS, + PLAN_STATUS_OPTIONS, + PLAN_STATUS_COLOR, + ITEM_TYPE_OPTIONS, +} from '../../api/health/carePlans'; +import { PageContainer } from '../../components/PageContainer'; +import { usePermission } from '../../hooks/usePermission'; + +const PLAN_TYPE_LABEL: Record = Object.fromEntries(PLAN_TYPE_OPTIONS.map((o) => [o.value, o.label])); +const PLAN_STATUS_LABEL: Record = Object.fromEntries(PLAN_STATUS_OPTIONS.map((o) => [o.value, o.label])); +const ITEM_TYPE_LABEL: Record = Object.fromEntries(ITEM_TYPE_OPTIONS.map((o) => [o.value, o.label])); + +export default function CarePlanDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { hasPermission } = usePermission('health.care-plan.manage'); + + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + + // Items + const [items, setItems] = useState([]); + const [itemsLoading, setItemsLoading] = useState(false); + const [itemModalOpen, setItemModalOpen] = useState(false); + const [editItem, setEditItem] = useState(null); + const [itemForm] = Form.useForm(); + + // Outcomes + const [outcomes, setOutcomes] = useState([]); + const [outcomesLoading, setOutcomesLoading] = useState(false); + const [outcomeModalOpen, setOutcomeModalOpen] = useState(false); + const [editOutcome, setEditOutcome] = useState(null); + const [outcomeForm] = Form.useForm(); + + const planId = id!; + + const fetchPlan = useCallback(async () => { + setLoading(true); + try { + const data = await carePlanApi.get(planId); + setPlan(data); + } catch { + message.error('加载计划详情失败'); + } finally { + setLoading(false); + } + }, [planId]); + + const fetchItems = useCallback(async () => { + setItemsLoading(true); + try { + const resp = await carePlanApi.listItems(planId, { page: 1, page_size: 100 }); + setItems(resp.data); + } catch { + message.error('加载干预项目失败'); + } finally { + setItemsLoading(false); + } + }, [planId]); + + const fetchOutcomes = useCallback(async () => { + setOutcomesLoading(true); + try { + const resp = await carePlanApi.listOutcomes(planId, { page: 1, page_size: 100 }); + setOutcomes(resp.data); + } catch { + message.error('加载预后测量失败'); + } finally { + setOutcomesLoading(false); + } + }, [planId]); + + useEffect(() => { + fetchPlan(); + fetchItems(); + fetchOutcomes(); + }, [fetchPlan, fetchItems, fetchOutcomes]); + + // --- Item CRUD --- + + const handleCreateItem = () => { + setEditItem(null); + itemForm.resetFields(); + setItemModalOpen(true); + }; + + const handleEditItem = (record: CarePlanItem) => { + setEditItem(record); + itemForm.setFieldsValue({ + item_type: record.item_type, + title: record.title, + description: record.description, + schedule: record.schedule, + sort_order: record.sort_order, + }); + setItemModalOpen(true); + }; + + const handleSubmitItem = async () => { + try { + const values = await itemForm.validateFields(); + if (editItem) { + await carePlanApi.updateItem(planId, editItem.id, { ...values, version: editItem.version }); + message.success('干预项目已更新'); + } else { + await carePlanApi.createItem(planId, values as CreateCarePlanItemReq); + message.success('干预项目已创建'); + } + setItemModalOpen(false); + fetchItems(); + } catch { + // validation + } + }; + + const handleDeleteItem = async (record: CarePlanItem) => { + try { + await carePlanApi.deleteItem(planId, record.id, record.version); + message.success('干预项目已删除'); + fetchItems(); + } catch { + message.error('删除失败'); + } + }; + + // --- Outcome CRUD --- + + const handleCreateOutcome = () => { + setEditOutcome(null); + outcomeForm.resetFields(); + setOutcomeModalOpen(true); + }; + + const handleEditOutcome = (record: CarePlanOutcome) => { + setEditOutcome(record); + outcomeForm.setFieldsValue({ + metric: record.metric, + baseline_value: record.baseline_value, + target_value: record.target_value, + current_value: record.current_value, + measured_at: record.measured_at ? dayjs(record.measured_at) : undefined, + notes: record.notes, + }); + setOutcomeModalOpen(true); + }; + + const handleSubmitOutcome = async () => { + try { + const values = await outcomeForm.validateFields(); + const req = { + ...values, + measured_at: values.measured_at?.format('YYYY-MM-DD'), + }; + if (editOutcome) { + await carePlanApi.updateOutcome(planId, editOutcome.id, { ...req, version: editOutcome.version }); + message.success('预后测量已更新'); + } else { + await carePlanApi.createOutcome(planId, req as CreateCarePlanOutcomeReq); + message.success('预后测量已创建'); + } + setOutcomeModalOpen(false); + fetchOutcomes(); + } catch { + // validation + } + }; + + const handleDeleteOutcome = async (record: CarePlanOutcome) => { + try { + await carePlanApi.deleteOutcome(planId, record.id, record.version); + message.success('预后测量已删除'); + fetchOutcomes(); + } catch { + message.error('删除失败'); + } + }; + + // --- Columns --- + + const itemColumns: ColumnsType = [ + { title: '类型', dataIndex: 'item_type', width: 120, render: (v: string) => ITEM_TYPE_LABEL[v] ?? v }, + { title: '标题', dataIndex: 'title', width: 200 }, + { title: '状态', dataIndex: 'status', width: 100, render: (v: string) => {v} }, + { title: '排期', dataIndex: 'schedule', width: 140, render: (v: string) => v ?? '-' }, + { title: '排序', dataIndex: 'sort_order', width: 70 }, + { + title: '操作', width: 140, render: (_, record) => hasPermission ? ( + + + handleDeleteItem(record)}> + + + + ) : null, + }, + ]; + + const outcomeColumns: ColumnsType = [ + { title: '指标', dataIndex: 'metric', width: 140 }, + { title: '基线值', dataIndex: 'baseline_value', width: 100 }, + { title: '目标值', dataIndex: 'target_value', width: 100 }, + { title: '当前值', dataIndex: 'current_value', width: 100, render: (v: string) => v ?? '-' }, + { title: '测量日期', dataIndex: 'measured_at', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' }, + { + title: '操作', width: 140, render: (_, record) => hasPermission ? ( + + + handleDeleteOutcome(record)}> + + + + ) : null, + }, + ]; + + if (loading) return ; + if (!plan) return ; + if (!hasPermission) return ; + + return ( + navigate('/health/care-plans')} + > + + {PLAN_TYPE_LABEL[plan.plan_type] ?? plan.plan_type} + + {PLAN_STATUS_LABEL[plan.status] ?? plan.status} + + {plan.patient_id} + {plan.start_date ?? '-'} + {plan.end_date ?? '-'} + {dayjs(plan.updated_at).format('YYYY-MM-DD HH:mm')} + {plan.notes && {plan.notes}} + + + + {hasPermission && ( + + )} + + rowKey="id" + columns={itemColumns} + dataSource={items} + loading={itemsLoading} + pagination={false} + size="small" + /> + + ), + }, + { + key: 'outcomes', + label: `预后测量 (${outcomes.length})`, + children: ( + <> + {hasPermission && ( + + )} + + rowKey="id" + columns={outcomeColumns} + dataSource={outcomes} + loading={outcomesLoading} + pagination={false} + size="small" + /> + + ), + }, + ]} + /> + + {/* Item Modal */} + setItemModalOpen(false)} + width={520} + > +
+ + + + + + + + + + + + +
+
+ + {/* Outcome Modal */} + setOutcomeModalOpen(false)} + width={520} + > +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/health/CarePlanList.tsx b/apps/web/src/pages/health/CarePlanList.tsx new file mode 100644 index 0000000..508e07c --- /dev/null +++ b/apps/web/src/pages/health/CarePlanList.tsx @@ -0,0 +1,273 @@ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { + 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 { + carePlanApi, + type CarePlan, + type CreateCarePlanReq, + type UpdateCarePlanReq, + PLAN_TYPE_OPTIONS, + PLAN_STATUS_OPTIONS, + PLAN_STATUS_COLOR, +} from '../../api/health/carePlans'; +import { PageContainer } from '../../components/PageContainer'; +import { usePermission } from '../../hooks/usePermission'; + +const PLAN_TYPE_LABEL: Record = Object.fromEntries( + PLAN_TYPE_OPTIONS.map((o) => [o.value, o.label]), +); + +const PLAN_STATUS_LABEL: Record = Object.fromEntries( + PLAN_STATUS_OPTIONS.map((o) => [o.value, o.label]), +); + +interface FilterValues { + plan_type?: string; + status?: string; +} + +export default function CarePlanList() { + const { hasPermission } = usePermission('health.care-plan.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 carePlanApi.list({ + page: p, + page_size: pageSize, + plan_type: f.plan_type || 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: CarePlan) => { + setEditRecord(record); + form.setFieldsValue({ + title: record.title, + plan_type: record.plan_type, + start_date: record.start_date ? dayjs(record.start_date) : undefined, + end_date: record.end_date ? dayjs(record.end_date) : undefined, + notes: record.notes, + }); + setModalOpen(true); + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + const req: CreateCarePlanReq | (UpdateCarePlanReq & { version: number }) = { + title: values.title, + plan_type: values.plan_type, + start_date: values.start_date?.format('YYYY-MM-DD'), + end_date: values.end_date?.format('YYYY-MM-DD'), + notes: values.notes, + ...(editRecord ? { version: editRecord.version } : { patient_id: values.patient_id }), + }; + + setSubmitting(true); + if (editRecord) { + await carePlanApi.update(editRecord.id, req as UpdateCarePlanReq & { version: number }); + message.success('护理计划已更新'); + } else { + await carePlanApi.create(req as CreateCarePlanReq); + message.success('护理计划已创建'); + } + setModalOpen(false); + fetchData(page, filters); + } catch { + // form validation + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (record: CarePlan) => { + try { + await carePlanApi.delete(record.id, record.version); + message.success('护理计划已删除'); + fetchData(page, filters); + } catch { + message.error('删除失败'); + } + }; + + const columns: ColumnsType = useMemo(() => [ + { + title: '计划名称', + dataIndex: 'title', + width: 200, + render: (title: string, record) => ( + navigate(`/health/care-plans/${record.id}`)}>{title} + ), + }, + { + title: '类型', + dataIndex: 'plan_type', + width: 120, + render: (v: string) => PLAN_TYPE_LABEL[v] ?? v, + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => ( + {PLAN_STATUS_LABEL[v] ?? v} + ), + }, + { + title: '开始日期', + dataIndex: 'start_date', + width: 120, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-', + }, + { + title: '结束日期', + dataIndex: 'end_date', + width: 120, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-', + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 170, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + width: 160, + render: (_, record) => ( + + + handleDelete(record)}> + + + + ), + }, + ], [navigate, page, filters]); + + if (!hasPermission) { + return ; + } + + return ( + 新建计划} + > + + 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={560} + > +
+ {!editRecord && ( + + + + )} + + + + +