feat(web): 护理计划 Web UI — Phase 2a-1
新增护理计划管理前端页面,接入后端 8 条孤立路由: - API 模块: carePlans.ts(计划 + 干预项目 + 预后测量 CRUD) - 列表页: CarePlanList.tsx(筛选/新建/编辑/删除/跳转详情) - 详情页: CarePlanDetail.tsx(计划信息 + Items/Outcomes 双 Tab CRUD) - 路由注册: /health/care-plans + /health/care-plans/:id - 菜单标题: routeTitleFallback 映射 权限: health.care-plan.list / health.care-plan.manage
This commit is contained in:
@@ -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() {
|
||||
<Route path="/health/dialysis" element={<DialysisManageList />} />
|
||||
<Route path="/health/action-inbox" element={<ActionInbox />} />
|
||||
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
|
||||
<Route path="/health/care-plans" element={<CarePlanList />} />
|
||||
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
|
||||
{/* 内容管理 */}
|
||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||
|
||||
245
apps/web/src/api/health/carePlans.ts
Normal file
245
apps/web/src/api/health/carePlans.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCarePlanReq {
|
||||
plan_type?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
goals?: Record<string, unknown>;
|
||||
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<string, string> = {
|
||||
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<CarePlan>;
|
||||
}>('/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<CarePlanItem>;
|
||||
}>(`/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<CarePlanOutcome>;
|
||||
}>(`/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 } });
|
||||
},
|
||||
};
|
||||
@@ -111,6 +111,8 @@ const routeTitleFallback: Record<string, string> = {
|
||||
'/health/devices': '设备管理',
|
||||
'/health/dialysis': '透析管理',
|
||||
'/health/follow-up-templates': '随访模板管理',
|
||||
'/health/care-plans': '护理计划',
|
||||
'/health/care-plans/:id': '护理计划详情',
|
||||
};
|
||||
|
||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||
|
||||
361
apps/web/src/pages/health/CarePlanDetail.tsx
Normal file
361
apps/web/src/pages/health/CarePlanDetail.tsx
Normal file
@@ -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<string, string> = Object.fromEntries(PLAN_TYPE_OPTIONS.map((o) => [o.value, o.label]));
|
||||
const PLAN_STATUS_LABEL: Record<string, string> = Object.fromEntries(PLAN_STATUS_OPTIONS.map((o) => [o.value, o.label]));
|
||||
const ITEM_TYPE_LABEL: Record<string, string> = 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<CarePlan | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Items
|
||||
const [items, setItems] = useState<CarePlanItem[]>([]);
|
||||
const [itemsLoading, setItemsLoading] = useState(false);
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [editItem, setEditItem] = useState<CarePlanItem | null>(null);
|
||||
const [itemForm] = Form.useForm();
|
||||
|
||||
// Outcomes
|
||||
const [outcomes, setOutcomes] = useState<CarePlanOutcome[]>([]);
|
||||
const [outcomesLoading, setOutcomesLoading] = useState(false);
|
||||
const [outcomeModalOpen, setOutcomeModalOpen] = useState(false);
|
||||
const [editOutcome, setEditOutcome] = useState<CarePlanOutcome | null>(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<CarePlanItem> = [
|
||||
{ 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) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
|
||||
{ title: '排期', dataIndex: 'schedule', width: 140, render: (v: string) => v ?? '-' },
|
||||
{ title: '排序', dataIndex: 'sort_order', width: 70 },
|
||||
{
|
||||
title: '操作', width: 140, render: (_, record) => hasPermission ? (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleEditItem(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteItem(record)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
const outcomeColumns: ColumnsType<CarePlanOutcome> = [
|
||||
{ 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 ? (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleEditOutcome(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteOutcome(record)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
if (!plan) return <Result status="404" title="计划不存在" />;
|
||||
if (!hasPermission) return <Result status="403" title="权限不足" />;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={plan.title}
|
||||
onBack={() => navigate('/health/care-plans')}
|
||||
>
|
||||
<Descriptions bordered size="small" column={3} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="类型">{PLAN_TYPE_LABEL[plan.plan_type] ?? plan.plan_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={PLAN_STATUS_COLOR[plan.status] ?? 'default'}>{PLAN_STATUS_LABEL[plan.status] ?? plan.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="患者 ID">{plan.patient_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始日期">{plan.start_date ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结束日期">{plan.end_date ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{dayjs(plan.updated_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
|
||||
{plan.notes && <Descriptions.Item label="备注" span={3}>{plan.notes}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="items"
|
||||
items={[
|
||||
{
|
||||
key: 'items',
|
||||
label: `干预项目 (${items.length})`,
|
||||
children: (
|
||||
<>
|
||||
{hasPermission && (
|
||||
<Button type="primary" size="small" style={{ marginBottom: 12 }} onClick={handleCreateItem}>
|
||||
添加干预项目
|
||||
</Button>
|
||||
)}
|
||||
<Table<CarePlanItem>
|
||||
rowKey="id"
|
||||
columns={itemColumns}
|
||||
dataSource={items}
|
||||
loading={itemsLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'outcomes',
|
||||
label: `预后测量 (${outcomes.length})`,
|
||||
children: (
|
||||
<>
|
||||
{hasPermission && (
|
||||
<Button type="primary" size="small" style={{ marginBottom: 12 }} onClick={handleCreateOutcome}>
|
||||
添加测量指标
|
||||
</Button>
|
||||
)}
|
||||
<Table<CarePlanOutcome>
|
||||
rowKey="id"
|
||||
columns={outcomeColumns}
|
||||
dataSource={outcomes}
|
||||
loading={outcomesLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Item Modal */}
|
||||
<Modal
|
||||
title={editItem ? '编辑干预项目' : '添加干预项目'}
|
||||
open={itemModalOpen}
|
||||
onOk={handleSubmitItem}
|
||||
onCancel={() => setItemModalOpen(false)}
|
||||
width={520}
|
||||
>
|
||||
<Form form={itemForm} layout="vertical">
|
||||
<Form.Item name="item_type" label="类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||
<Select options={ITEM_TYPE_OPTIONS} placeholder="选择类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input placeholder="如:血压监测" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="schedule" label="排期">
|
||||
<Input placeholder="如:每日、每周一" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Outcome Modal */}
|
||||
<Modal
|
||||
title={editOutcome ? '编辑预后测量' : '添加测量指标'}
|
||||
open={outcomeModalOpen}
|
||||
onOk={handleSubmitOutcome}
|
||||
onCancel={() => setOutcomeModalOpen(false)}
|
||||
width={520}
|
||||
>
|
||||
<Form form={outcomeForm} layout="vertical">
|
||||
<Form.Item name="metric" label="指标名称" rules={[{ required: true, message: '请输入指标' }]}>
|
||||
<Input placeholder="如:收缩压、血磷" />
|
||||
</Form.Item>
|
||||
<Space style={{ width: '100%' }} size="middle">
|
||||
<Form.Item name="baseline_value" label="基线值" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 200 }} placeholder="如:150" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_value" label="目标值" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 200 }} placeholder="如:130" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="current_value" label="当前值">
|
||||
<Input placeholder="最新测量结果" />
|
||||
</Form.Item>
|
||||
<Form.Item name="measured_at" label="测量日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
273
apps/web/src/pages/health/CarePlanList.tsx
Normal file
273
apps/web/src/pages/health/CarePlanList.tsx
Normal file
@@ -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<string, string> = Object.fromEntries(
|
||||
PLAN_TYPE_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
const PLAN_STATUS_LABEL: Record<string, string> = 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<CarePlan[]>([]);
|
||||
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<CarePlan | 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 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<CarePlan> = useMemo(() => [
|
||||
{
|
||||
title: '计划名称',
|
||||
dataIndex: 'title',
|
||||
width: 200,
|
||||
render: (title: string, record) => (
|
||||
<a onClick={() => navigate(`/health/care-plans/${record.id}`)}>{title}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'plan_type',
|
||||
width: 120,
|
||||
render: (v: string) => PLAN_TYPE_LABEL[v] ?? v,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => (
|
||||
<Tag color={PLAN_STATUS_COLOR[v] ?? 'default'}>{PLAN_STATUS_LABEL[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Space>
|
||||
<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>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="计划类型"
|
||||
options={PLAN_TYPE_OPTIONS}
|
||||
value={filters.plan_type}
|
||||
onChange={(v) => handleFilterChange('plan_type', v)}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
options={PLAN_STATUS_OPTIONS}
|
||||
value={filters.status}
|
||||
onChange={(v) => handleFilterChange('status', v)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Button onClick={handleResetFilters}>重置</Button>
|
||||
</Space>
|
||||
|
||||
<Table<CarePlan>
|
||||
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={560}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{!editRecord && (
|
||||
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true, message: '请输入患者 ID' }]}>
|
||||
<Input placeholder="输入患者 UUID" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="title" label="计划名称" rules={[{ required: true, message: '请输入计划名称' }]}>
|
||||
<Input placeholder="如:血管通路护理计划" />
|
||||
</Form.Item>
|
||||
<Form.Item name="plan_type" label="计划类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||
<Select options={PLAN_TYPE_OPTIONS} placeholder="选择类型" />
|
||||
</Form.Item>
|
||||
<Space style={{ width: '100%' }} size="middle">
|
||||
<Form.Item name="start_date" label="开始日期">
|
||||
<DatePicker style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="end_date" label="结束日期">
|
||||
<DatePicker style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="计划备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user