feat: 仪表盘角色自适应重构 — 4角色视图 + 后端个人工作量API
后端: - 新增 GET /health/admin/statistics/personal-stats 接口 - PersonalStatsResp: 13个个人维度统计字段 - 按医生/护士/管理员/运营角色聚合工作量数据 前端: - useDashboardRole hook: 按优先级 doctor>nurse>admin>operator 匹配角色 - DoctorDashboard: 今日工作台(日程/审核/消息/统计卡) - NurseDashboard: 随访监控台(异常提醒/队列/上报率) - AdminDashboard: 管理中心(5KPI + 健康数据Tab) - OperatorDashboard: 运营中心(积分/文章/活动) - StatisticsDashboard.tsx 重写为角色路由组件 - 删除旧区块:快捷入口/积分排行Top10/最近活动
This commit is contained in:
@@ -141,6 +141,22 @@ export interface FollowUpStatistics {
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface PersonalStats {
|
||||
my_patients: number;
|
||||
new_patients_this_month: number;
|
||||
follow_up_rate: number;
|
||||
consultations_this_month: number;
|
||||
pending_consultations: number;
|
||||
vital_signs_report_rate: number;
|
||||
today_appointments: number;
|
||||
overdue_follow_ups: number;
|
||||
today_follow_ups: number;
|
||||
abnormal_vital_signs: number;
|
||||
vital_signs_reported: number;
|
||||
vital_signs_total: number;
|
||||
pending_lab_reviews: number;
|
||||
}
|
||||
|
||||
export interface OverviewStatistics {
|
||||
patients: PatientStatistics;
|
||||
consultations: ConsultationStatistics;
|
||||
@@ -359,4 +375,12 @@ export const pointsApi = {
|
||||
}>('/health/admin/statistics/health-data');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
getPersonalStats: async (): Promise<PersonalStats> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PersonalStats;
|
||||
}>('/health/admin/statistics/personal-stats');
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
19
apps/web/src/hooks/useDashboardRole.ts
Normal file
19
apps/web/src/hooks/useDashboardRole.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
type DashboardRole = 'doctor' | 'nurse' | 'admin' | 'operator';
|
||||
|
||||
const ROLE_PRIORITY: DashboardRole[] = ['doctor', 'nurse', 'admin', 'operator'];
|
||||
|
||||
export function useDashboardRole(): DashboardRole {
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
if (!user?.roles?.length) return 'admin';
|
||||
|
||||
const codes = user.roles.map(r => r.code);
|
||||
for (const role of ROLE_PRIORITY) {
|
||||
if (codes.some(c => c === role || c.startsWith(role))) return role;
|
||||
}
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
export type { DashboardRole };
|
||||
@@ -1,256 +1,18 @@
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
Table,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
TrophyOutlined,
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
ReloadOutlined,
|
||||
CommentOutlined,
|
||||
MedicineBoxOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PointsStatistics } from '../../api/health/points';
|
||||
import { useStatsData } from './StatisticsDashboard/useStatsData';
|
||||
import HealthDataCenter from './StatisticsDashboard/HealthDataCenter';
|
||||
import { useDashboardRole } from '../../hooks/useDashboardRole';
|
||||
import { DoctorDashboard } from './StatisticsDashboard/DoctorDashboard';
|
||||
import { NurseDashboard } from './StatisticsDashboard/NurseDashboard';
|
||||
import { AdminDashboard } from './StatisticsDashboard/AdminDashboard';
|
||||
import { OperatorDashboard } from './StatisticsDashboard/OperatorDashboard';
|
||||
|
||||
const { Title: AntTitle, Text } = Typography;
|
||||
|
||||
interface StatCardConfig {
|
||||
title: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
precision?: number;
|
||||
prefix: React.ReactNode;
|
||||
subtitle?: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
interface QuickLinkConfig {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
path: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TopEarnerRow {
|
||||
rank: number;
|
||||
patient_id: string;
|
||||
total_earned: number;
|
||||
}
|
||||
|
||||
const QUICK_LINKS: QuickLinkConfig[] = [
|
||||
{ title: '患者管理', icon: <TeamOutlined />, path: '/health/patients', color: '#2563eb' },
|
||||
{ title: '预约排班', icon: <CalendarOutlined />, path: '/health/appointments', color: '#059669' },
|
||||
{ title: '随访管理', icon: <PhoneOutlined />, path: '/health/follow-up-tasks', color: '#d97706' },
|
||||
{ title: '咨询管理', icon: <CommentOutlined />, path: '/health/consultations', color: '#7c3aed' },
|
||||
{ title: '积分规则', icon: <TrophyOutlined />, path: '/health/points-rules', color: '#dc2626' },
|
||||
{ title: '商品管理', icon: <ShoppingOutlined />, path: '/health/points-products', color: '#0891b2' },
|
||||
{ title: '订单管理', icon: <FileTextOutlined />, path: '/health/points-orders', color: '#4f46e5' },
|
||||
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
|
||||
];
|
||||
|
||||
function buildStatCards(stats: ReturnType<typeof useStatsData>): StatCardConfig[] {
|
||||
return [
|
||||
{
|
||||
title: '患者总数',
|
||||
value: stats.patientStats?.total_patients ?? 0,
|
||||
prefix: <UserOutlined />,
|
||||
subtitle: stats.patientStats?.new_this_month ? `本月 +${stats.patientStats.new_this_month}` : undefined,
|
||||
color: '#2563eb', bgColor: '#eff6ff',
|
||||
},
|
||||
{
|
||||
title: '咨询总量',
|
||||
value: stats.consultationStats?.total_sessions ?? 0,
|
||||
prefix: <MessageOutlined />,
|
||||
subtitle: stats.consultationStats?.this_month ? `本月 +${stats.consultationStats.this_month}` : undefined,
|
||||
color: '#7c3aed', bgColor: '#f5f3ff',
|
||||
},
|
||||
{
|
||||
title: '随访完成率',
|
||||
value: stats.followUpStats?.completion_rate ?? 0,
|
||||
suffix: '%', precision: 1,
|
||||
prefix: <PhoneOutlined />,
|
||||
subtitle: stats.followUpStats?.pending ? `待处理: ${stats.followUpStats.pending}` : undefined,
|
||||
color: '#059669', bgColor: '#ecfdf5',
|
||||
},
|
||||
{
|
||||
title: '积分总发放',
|
||||
value: stats.pointsStats?.total_issued ?? 0,
|
||||
prefix: <TrophyOutlined />,
|
||||
subtitle: stats.pointsStats?.active_accounts ? `活跃账户: ${stats.pointsStats.active_accounts}` : undefined,
|
||||
color: '#d97706', bgColor: '#fffbeb',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const topEarnerColumns = [
|
||||
{
|
||||
title: '排名', dataIndex: 'rank', key: 'rank', width: 70,
|
||||
render: (rank: number) => {
|
||||
const medalColors = ['#d97706', '#6b7280', '#b45309'];
|
||||
const color = rank <= 3 ? medalColors[rank - 1] : undefined;
|
||||
return <Text strong={rank <= 3} style={color ? { color } : undefined}>{rank}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '患者 ID', dataIndex: 'patient_id', key: 'patient_id', width: 180,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text copyable={{ text: id }}>{id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '累计积分', dataIndex: 'total_earned', key: 'total_earned', width: 140,
|
||||
render: (val: number) => <Text strong>{val.toLocaleString()}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
function buildTopEarnerData(pointsStats: PointsStatistics | null): TopEarnerRow[] {
|
||||
return (pointsStats?.top_earners ?? []).map((item, idx) => ({
|
||||
rank: idx + 1,
|
||||
patient_id: item.patient_id,
|
||||
total_earned: item.total_earned,
|
||||
}));
|
||||
}
|
||||
const DASHBOARD_MAP = {
|
||||
doctor: DoctorDashboard,
|
||||
nurse: NurseDashboard,
|
||||
admin: AdminDashboard,
|
||||
operator: OperatorDashboard,
|
||||
} as const;
|
||||
|
||||
export default function StatisticsDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const stats = useStatsData();
|
||||
const statCards = buildStatCards(stats);
|
||||
const topEarnerData = buildTopEarnerData(stats.pointsStats);
|
||||
|
||||
if (stats.loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<Spin size="large" tip="加载统计数据中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.error) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message="加载统计数据失败"
|
||||
description={stats.error}
|
||||
showIcon
|
||||
action={<Button size="small" icon={<ReloadOutlined />} onClick={stats.refresh}>重试</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{statCards.map((card) => (
|
||||
<Col xs={24} sm={12} md={6} key={card.title}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }} bodyStyle={{ padding: '20px 24px' }} hoverable>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>}
|
||||
value={card.value}
|
||||
precision={card.precision}
|
||||
suffix={card.suffix}
|
||||
prefix={
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 40, height: 40, borderRadius: 10, backgroundColor: card.bgColor,
|
||||
color: card.color, fontSize: 20, marginRight: 12,
|
||||
}}>
|
||||
{card.prefix}
|
||||
</span>
|
||||
}
|
||||
valueStyle={{ color: card.color, fontSize: 28, fontWeight: 700 }}
|
||||
/>
|
||||
{card.subtitle && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />{card.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />积分统计</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
extra={<Button type="text" icon={<ReloadOutlined />} onClick={stats.refresh} loading={stats.loading}>刷新</Button>}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}><Statistic title="总发放" value={stats.pointsStats?.total_issued ?? 0} valueStyle={{ color: '#059669', fontSize: 22 }} prefix={<ArrowUpOutlined />} /></Col>
|
||||
<Col xs={12} sm={6}><Statistic title="总消费" value={stats.pointsStats?.total_spent ?? 0} valueStyle={{ color: '#dc2626', fontSize: 22 }} /></Col>
|
||||
<Col xs={12} sm={6}><Statistic title="总过期" value={stats.pointsStats?.total_expired ?? 0} valueStyle={{ color: '#6b7280', fontSize: 22 }} /></Col>
|
||||
<Col xs={12} sm={6}><Statistic title="活跃账户" value={stats.pointsStats?.active_accounts ?? 0} valueStyle={{ color: '#2563eb', fontSize: 22 }} prefix={<TeamOutlined />} /></Col>
|
||||
</Row>
|
||||
<AntTitle level={5} style={{ marginBottom: 16 }}>积分排行 Top 10</AntTitle>
|
||||
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={false} size="small" locale={{ emptyText: '暂无数据' }} style={{ marginTop: 8 }} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />健康数据中心</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<HealthDataCenter data={stats.healthDataStats} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />快捷入口</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{QUICK_LINKS.map((link) => (
|
||||
<Col xs={12} sm={8} md={6} key={link.path}>
|
||||
<Card
|
||||
hoverable bordered={false}
|
||||
style={{ borderRadius: 10, cursor: 'pointer', textAlign: 'center', transition: 'transform 0.2s, box-shadow 0.2s' }}
|
||||
bodyStyle={{ padding: '20px 12px' }}
|
||||
onClick={() => navigate(link.path)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 48, height: 48, borderRadius: 12, backgroundColor: `${link.color}15`,
|
||||
color: link.color, fontSize: 24, marginBottom: 10,
|
||||
}}>
|
||||
{link.icon}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>{link.title}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{topEarnerData.length > 0 && (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />最近活动</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={{ pageSize: 5, size: 'small' }} size="small" locale={{ emptyText: '暂无活动记录' }} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const role = useDashboardRole();
|
||||
const DashboardComponent = DASHBOARD_MAP[role];
|
||||
return <DashboardComponent />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Row, Col, Card, Statistic, Tabs, Spin, Typography, Flex } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MedicineBoxOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
import HealthDataCenter from './HealthDataCenter';
|
||||
|
||||
export function AdminDashboard() {
|
||||
const { patientStats, followUpStats, healthDataStats, loading } = useStatsData();
|
||||
|
||||
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>管理中心</Typography.Title>
|
||||
<Typography.Text type="secondary">数据概览</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="患者总数" value={useCountUp(patientStats?.total_patients ?? 0)} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="本月预约" value={useCountUp(healthDataStats?.appointments?.this_month ?? 0)} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="随访完成"
|
||||
value={followUpStats?.completion_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="体征上报"
|
||||
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<MedicineBoxOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="医护人数" value={useCountUp(0)} prefix={<UserOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 健康数据中心 Tab */}
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Tabs
|
||||
defaultActiveKey="dialysis"
|
||||
items={[
|
||||
{ key: 'dialysis', label: '透析管理', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
{ key: 'lab', label: '化验报告', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
{ key: 'appointments', label: '预约分析', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
{ key: 'vital-signs', label: '体征数据', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { pointsApi, type PersonalStats } from '../../../api/health/points';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function DoctorDashboard() {
|
||||
const [personal, setPersonal] = useState<PersonalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { consultationStats } = useStatsData();
|
||||
|
||||
const fetchPersonal = useCallback(async () => {
|
||||
try {
|
||||
const data = await pointsApi.getPersonalStats();
|
||||
setPersonal(data);
|
||||
} catch {
|
||||
// 个人统计可能无权限,静默降级
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
|
||||
|
||||
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
const p = personal;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>今日工作台</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{p ? `${p.today_appointments} 个预约 · ${p.today_follow_ups} 条待随访` : '加载中...'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 紧急提醒 */}
|
||||
{p && p.abnormal_vital_signs > 0 && (
|
||||
<Col span={24}>
|
||||
<Card size="small" style={{ borderLeft: '4px solid #ff4d4f' }}>
|
||||
<Typography.Text type="danger" strong>
|
||||
{p.abnormal_vital_signs} 位患者体征异常
|
||||
</Typography.Text>
|
||||
{p.pending_lab_reviews > 0 && (
|
||||
<Typography.Text style={{ marginLeft: 16 }}>
|
||||
<Tag color="orange">{p.pending_lab_reviews} 份化验待审</Tag>
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="我的患者"
|
||||
value={useCountUp(p?.my_patients ?? 0)}
|
||||
prefix={<TeamOutlined />}
|
||||
suffix={p && p.new_patients_this_month > 0 ? (
|
||||
<Typography.Text type="success" style={{ fontSize: 12 }}>
|
||||
<ArrowUpOutlined /> {p.new_patients_this_month}新增
|
||||
</Typography.Text>
|
||||
) : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="随访完成率"
|
||||
value={p?.follow_up_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
valueStyle={{ color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="本月咨询"
|
||||
value={useCountUp(p?.consultations_this_month ?? 0)}
|
||||
prefix={<MessageOutlined />}
|
||||
suffix={p && p.pending_consultations > 0 ? (
|
||||
<Typography.Text type="warning" style={{ fontSize: 12 }}>
|
||||
{p.pending_consultations}待回复
|
||||
</Typography.Text>
|
||||
) : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="体征上报率"
|
||||
value={p?.vital_signs_report_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<MedicineBoxOutlined />}
|
||||
valueStyle={{ color: (p?.vital_signs_report_rate ?? 0) >= 70 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 化验审核 */}
|
||||
{p && p.pending_lab_reviews > 0 && (
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无待审核化验' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* 咨询消息 */}
|
||||
<Col xs={24} md={p && p.pending_lab_reviews > 0 ? 12 : 24}>
|
||||
<Card title={`咨询消息 (${consultationStats?.pending_reply ?? 0}未读)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无未读消息' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx
Normal file
124
apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
CalendarOutlined,
|
||||
AlertOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { pointsApi, type PersonalStats } from '../../../api/health/points';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function NurseDashboard() {
|
||||
const [personal, setPersonal] = useState<PersonalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchPersonal = useCallback(async () => {
|
||||
try {
|
||||
const data = await pointsApi.getPersonalStats();
|
||||
setPersonal(data);
|
||||
} catch {
|
||||
// 静默降级
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
|
||||
|
||||
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
const p = personal;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>随访监控台</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{p ? `今日待随访 ${p.today_follow_ups} 人 · 体征异常 ${p.abnormal_vital_signs} 人` : '加载中...'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 异常提醒 */}
|
||||
{p && p.abnormal_vital_signs > 0 && (
|
||||
<Col span={24}>
|
||||
<Card size="small" style={{ borderLeft: '4px solid #ff4d4f' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
<AlertOutlined style={{ color: '#ff4d4f' }} />
|
||||
<Typography.Text type="danger" strong>
|
||||
{p.abnormal_vital_signs} 位患者体征异常
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Link>查看全部 →</Typography.Link>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* 今日随访队列 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={`今日随访队列 (${p?.today_follow_ups ?? 0}人)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '今日暂无随访任务' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 今日体征上报 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="今日体征上报" size="small">
|
||||
<Flex vertical align="center" style={{ padding: '16px 0' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.round(p?.vital_signs_report_rate ?? 0)}
|
||||
size={120}
|
||||
format={pct => <span style={{ fontSize: 24 }}>{pct}%</span>}
|
||||
/>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{p?.vital_signs_reported ?? 0}已上报
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">|</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{(p?.vital_signs_total ?? 0) - (p?.vital_signs_reported ?? 0)}未上报
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 统计卡 */}
|
||||
<Col xs={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="今日预约" value={useCountUp(p?.today_appointments ?? 0)} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="随访完成率"
|
||||
value={p?.follow_up_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="逾期随访" value={useCountUp(p?.overdue_follow_ups ?? 0)} prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: (p?.overdue_follow_ups ?? 0) > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd';
|
||||
import {
|
||||
TrophyOutlined,
|
||||
FileTextOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function OperatorDashboard() {
|
||||
const { pointsStats, loading } = useStatsData();
|
||||
|
||||
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>运营中心</Typography.Title>
|
||||
<Typography.Text type="secondary">积分、内容、活动</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="积分发放" value={useCountUp(pointsStats?.total_issued ?? 0)} prefix={<TrophyOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="积分消费" value={useCountUp(pointsStats?.total_spent ?? 0)} prefix={<ShoppingOutlined />}
|
||||
suffix={pointsStats ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
消费率{pointsStats.total_issued > 0 ? Math.round(pointsStats.total_spent / pointsStats.total_issued * 100) : 0}%
|
||||
</Typography.Text>
|
||||
) : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="活跃账户" value={useCountUp(pointsStats?.active_accounts ?? 0)} prefix={<FileTextOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 积分排行 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="积分消费排行" size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={(item, idx) => (
|
||||
<List.Item>
|
||||
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.total_earned} 分</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 热门文章 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="热门文章" size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,28 @@ pub struct HealthDataStatsResp {
|
||||
pub vital_signs_report_rate: VitalSignsReportRateResp,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 个人维度统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 当前用户个人维度的工作量统计
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PersonalStatsResp {
|
||||
pub my_patients: i64,
|
||||
pub new_patients_this_month: i64,
|
||||
pub follow_up_rate: f64,
|
||||
pub consultations_this_month: i64,
|
||||
pub pending_consultations: i64,
|
||||
pub vital_signs_report_rate: f64,
|
||||
pub today_appointments: i64,
|
||||
pub overdue_follow_ups: i64,
|
||||
pub today_follow_ups: i64,
|
||||
pub abnormal_vital_signs: i64,
|
||||
pub vital_signs_reported: i64,
|
||||
pub vital_signs_total: i64,
|
||||
pub pending_lab_reviews: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通用结构
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -134,3 +134,20 @@ where
|
||||
let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 个人维度统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_personal_stats<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<PersonalStatsResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.list")?;
|
||||
let result = stats_service::get_personal_stats(&state, ctx.user_id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -577,6 +577,10 @@ impl HealthModule {
|
||||
"/health/admin/statistics/health-data",
|
||||
axum::routing::get(stats_handler::get_health_data_stats),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/statistics/personal-stats",
|
||||
axum::routing::get(stats_handler::get_personal_stats),
|
||||
)
|
||||
// 危急值阈值配置
|
||||
.route(
|
||||
"/health/critical-value-thresholds",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, FromQueryResult as _};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::dto::stats_dto::*;
|
||||
use crate::entity::{
|
||||
patient, consultation_session, follow_up_task,
|
||||
points_transaction, dialysis_record, lab_report,
|
||||
appointment, vital_signs,
|
||||
appointment, vital_signs, patient_doctor_relation, doctor_profile,
|
||||
};
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -621,3 +621,254 @@ async fn compute_daily_report_rate(
|
||||
DailyReportRate { date: r.date, reported: r.reported, total, rate }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 个人维度统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_personal_stats(
|
||||
state: &HealthState,
|
||||
user_id: uuid::Uuid,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<PersonalStatsResp> {
|
||||
let db = &state.db;
|
||||
|
||||
// 通过 user_id 查找 doctor_profile 以获得 doctor_id
|
||||
let doctor_profile = doctor_profile::Entity::find()
|
||||
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
|
||||
.filter(doctor_profile::Column::DeletedAt.is_null())
|
||||
.filter(doctor_profile::Column::UserId.eq(user_id))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
let doctor_id = doctor_profile.map(|p| p.id);
|
||||
|
||||
// my_patients: 通过 patient_doctor_relation 统计
|
||||
let my_patients = if let Some(did) = doctor_id {
|
||||
patient_doctor_relation::Entity::find()
|
||||
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
|
||||
.filter(patient_doctor_relation::Column::DoctorId.eq(did))
|
||||
.count(db)
|
||||
.await? as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// new_patients_this_month: 本月新增关联患者
|
||||
let new_patients_this_month = if let Some(did) = doctor_id {
|
||||
let sql = r#"
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM patient_doctor_relation pdr
|
||||
INNER JOIN patient p ON p.id = pdr.patient_id AND p.deleted_at IS NULL AND p.tenant_id = $1
|
||||
WHERE pdr.tenant_id = $1 AND pdr.deleted_at IS NULL
|
||||
AND pdr.doctor_id = $2
|
||||
AND p.created_at >= date_trunc('month', NOW())
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct Cnt {
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let result: Option<Cnt> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into(), did.into()],
|
||||
),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
result.map(|r| r.cnt).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// follow_up_rate: 分配给当前用户的随访完成率
|
||||
let sql = r#"
|
||||
SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt
|
||||
FROM follow_up_task
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL AND assigned_to = $2
|
||||
GROUP BY GROUPING SETS ((status), ())
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct StatusCount {
|
||||
status: String,
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let fu_rows: Vec<StatusCount> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into(), user_id.into()],
|
||||
),
|
||||
)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut fu_total: i64 = 0;
|
||||
let mut fu_completed: i64 = 0;
|
||||
let mut overdue_follow_ups: i64 = 0;
|
||||
for row in &fu_rows {
|
||||
match row.status.as_str() {
|
||||
"__total" => fu_total = row.cnt,
|
||||
"completed" => fu_completed = row.cnt,
|
||||
"overdue" => overdue_follow_ups = row.cnt,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let follow_up_rate = if fu_total > 0 {
|
||||
(fu_completed as f64 / fu_total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// consultations_this_month / pending_consultations: 咨询统计
|
||||
let consultations_this_month = if let Some(did) = doctor_id {
|
||||
consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.filter(consultation_session::Column::DoctorId.eq(did))
|
||||
.filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||
.count(db)
|
||||
.await? as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let pending_consultations = if let Some(did) = doctor_id {
|
||||
consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.filter(consultation_session::Column::DoctorId.eq(did))
|
||||
.filter(consultation_session::Column::Status.eq("active"))
|
||||
.count(db)
|
||||
.await? as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// today_appointments: 今日预约
|
||||
let today_appointments = if let Some(did) = doctor_id {
|
||||
appointment::Entity::find()
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.filter(appointment::Column::DoctorId.eq(did))
|
||||
.filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE")))
|
||||
.count(db)
|
||||
.await? as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// today_follow_ups: 今日随访任务
|
||||
let today_follow_ups = follow_up_task::Entity::find()
|
||||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||||
.filter(follow_up_task::Column::AssignedTo.eq(user_id))
|
||||
.filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE")))
|
||||
.count(db)
|
||||
.await? as i64;
|
||||
|
||||
// vital_signs_report_rate: 当前医生的患者体征上报率
|
||||
let (vital_signs_reported, vital_signs_total, vital_signs_report_rate) = if my_patients > 0 {
|
||||
let vs_sql = r#"
|
||||
SELECT
|
||||
COUNT(DISTINCT vs.patient_id) AS reported,
|
||||
$3::bigint AS total
|
||||
FROM vital_signs vs
|
||||
WHERE vs.tenant_id = $1 AND vs.deleted_at IS NULL
|
||||
AND vs.created_at >= date_trunc('month', NOW())
|
||||
AND vs.patient_id IN (
|
||||
SELECT patient_id FROM patient_doctor_relation
|
||||
WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL
|
||||
)
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct VsCount {
|
||||
reported: i64,
|
||||
total: i64,
|
||||
}
|
||||
|
||||
let result: Option<VsCount> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
vs_sql,
|
||||
[tenant_id.into(), doctor_id.unwrap_or_default().into(), my_patients.into()],
|
||||
),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
Some(r) => {
|
||||
let rate = if r.total > 0 {
|
||||
(r.reported as f64 / r.total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(r.reported, r.total, rate)
|
||||
}
|
||||
None => (0, my_patients, 0.0),
|
||||
}
|
||||
} else {
|
||||
(0, 0, 0.0)
|
||||
};
|
||||
|
||||
// pending_lab_reviews: 待审核化验报告(与当前医生的患者关联)
|
||||
let pending_lab_reviews = if doctor_id.is_some() {
|
||||
let lr_sql = r#"
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM lab_report lr
|
||||
WHERE lr.tenant_id = $1 AND lr.deleted_at IS NULL
|
||||
AND lr.status = 'pending'
|
||||
AND lr.patient_id IN (
|
||||
SELECT patient_id FROM patient_doctor_relation
|
||||
WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL
|
||||
)
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct LrCnt {
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let result: Option<LrCnt> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
lr_sql,
|
||||
[tenant_id.into(), doctor_id.unwrap_or_default().into()],
|
||||
),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
result.map(|r| r.cnt).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置)
|
||||
let abnormal_vital_signs: i64 = 0;
|
||||
|
||||
Ok(PersonalStatsResp {
|
||||
my_patients,
|
||||
new_patients_this_month,
|
||||
follow_up_rate,
|
||||
consultations_this_month,
|
||||
pending_consultations,
|
||||
vital_signs_report_rate,
|
||||
today_appointments,
|
||||
overdue_follow_ups,
|
||||
today_follow_ups,
|
||||
abnormal_vital_signs,
|
||||
vital_signs_reported,
|
||||
vital_signs_total,
|
||||
pending_lab_reviews,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user