428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
import type { PageResult, ApiResponse } from '@/types/common'
|
|
import { unwrapApiResponse } from '@/utils/api-response'
|
|
import { request } from '@/utils/request'
|
|
|
|
import type { SigningMethod, SigningTaskStatus, WorkbenchCampus } from './types'
|
|
|
|
export interface ReportTaskRow {
|
|
id: string
|
|
patientId: string
|
|
patientName: string
|
|
campus: WorkbenchCampus
|
|
department: string
|
|
documentName: string
|
|
category: string
|
|
method: SigningMethod
|
|
status: SigningTaskStatus
|
|
createdAt: string
|
|
signedAt?: string
|
|
signMinutes?: number
|
|
}
|
|
|
|
export interface ReportGroupRow {
|
|
key: string
|
|
total: number
|
|
signed: number
|
|
confirming: number
|
|
pending: number
|
|
signing: number
|
|
expired: number
|
|
void: number
|
|
failed: number
|
|
completionRate: number
|
|
}
|
|
|
|
export interface ReportBarItem {
|
|
label: string
|
|
value: number
|
|
display?: string
|
|
color?: string
|
|
}
|
|
|
|
export interface ReportAnalyticsDataset {
|
|
timezone: string
|
|
generatedAt: string
|
|
fromDate: string
|
|
toDate: string
|
|
statusCounts: Omit<ReportGroupRow, 'key' | 'completionRate'>
|
|
trend: { key: string; label: string; created: number; signed: number }[]
|
|
departments: ReportGroupRow[]
|
|
categories: ReportGroupRow[]
|
|
methods: ReportGroupRow[]
|
|
campuses: ReportGroupRow[]
|
|
documents: ReportGroupRow[]
|
|
timeliness: {
|
|
averageMinutes: number | null
|
|
within24hRate: number | null
|
|
overdueRate: number | null
|
|
expiredTaskCount: number
|
|
signedSampleCount: number
|
|
departmentAverages: ReportBarItem[]
|
|
dailyAverages: { date: string; averageMinutes: number; sampleCount: number }[]
|
|
overdueTaskCount: number
|
|
overdueTasks: {
|
|
id: string
|
|
patientId: string
|
|
patientName: string
|
|
department: string
|
|
documentName: string
|
|
createdAt: string
|
|
signedAt: string
|
|
signMinutes: number
|
|
}[]
|
|
}
|
|
patientStructure: {
|
|
age: ReportBarItem[]
|
|
sex: ReportBarItem[]
|
|
visitType: ReportBarItem[]
|
|
campus: ReportBarItem[]
|
|
}
|
|
}
|
|
|
|
export interface ReportOptions {
|
|
campuses: { id: string; name: string }[]
|
|
departments: { id: string; campusId: string; name: string }[]
|
|
templateCategories: string[]
|
|
}
|
|
|
|
export interface ReportAnalyticsQuery {
|
|
createdFrom: string
|
|
createdTo: string
|
|
timezone: string
|
|
campusId?: string
|
|
departmentId?: string
|
|
templateCategory?: string
|
|
signMethod?: 'PAD' | 'SMS'
|
|
}
|
|
|
|
export interface ReportFilterInput {
|
|
dateRange: '7d' | '14d' | 'month' | 'custom'
|
|
customFrom: string
|
|
customTo: string
|
|
campus: string
|
|
department: string
|
|
category: string
|
|
method: SigningMethod | 'all'
|
|
}
|
|
|
|
interface ReportGroupDto {
|
|
key: string
|
|
total: number
|
|
signed: number
|
|
confirming: number
|
|
pending: number
|
|
signing: number
|
|
expired: number
|
|
voided: number
|
|
failed: number
|
|
completionRate: number
|
|
}
|
|
|
|
interface ReportAnalyticsDto {
|
|
timezone: string
|
|
generatedAt: string
|
|
fromDate: string
|
|
toDate: string
|
|
statusCounts: {
|
|
total: number
|
|
signed: number
|
|
confirming: number
|
|
pending: number
|
|
signing: number
|
|
expired: number
|
|
voided: number
|
|
failed: number
|
|
}
|
|
trend: { date: string; created: number; signed: number }[]
|
|
departments: ReportGroupDto[]
|
|
categories: ReportGroupDto[]
|
|
methods: ReportGroupDto[]
|
|
campuses: ReportGroupDto[]
|
|
documents: ReportGroupDto[]
|
|
timeliness: {
|
|
averageMinutes: number | null
|
|
within24hRate: number | null
|
|
overdueRate: number | null
|
|
expiredTaskCount: number
|
|
signedSampleCount: number
|
|
departmentAverages: { label: string; averageMinutes: number; sampleCount: number }[]
|
|
dailyAverages: { label: string; averageMinutes: number; sampleCount: number }[]
|
|
overdueTaskCount: number
|
|
overdueTasks: {
|
|
taskId: string
|
|
patientDisplayId: string
|
|
patientNameMasked: string
|
|
department: string
|
|
documentName: string
|
|
createdAt: string
|
|
signedAt: string
|
|
elapsedMinutes: number
|
|
}[]
|
|
}
|
|
patientStructure: {
|
|
age: { label: string; value: number }[]
|
|
sex: { label: string; value: number }[]
|
|
visitType: { label: string; value: number }[]
|
|
campus: { label: string; value: number }[]
|
|
}
|
|
}
|
|
|
|
interface ReportOptionsDto {
|
|
campuses: { id: string; name: string }[]
|
|
departments: { id: string; campusId: string; name: string }[]
|
|
templateCategories: string[]
|
|
}
|
|
|
|
interface ReportTaskDto {
|
|
taskId: string
|
|
patientDisplayId: string
|
|
patientNameMasked: string
|
|
status: string
|
|
signMethod: string
|
|
createdAt: string
|
|
signedAt: string | null
|
|
templateName: string
|
|
templateCategory: string
|
|
campusName: string
|
|
departmentName: string
|
|
}
|
|
|
|
interface BackendPage<T> {
|
|
records?: T[]
|
|
items?: T[]
|
|
total?: number
|
|
page?: number
|
|
size?: number
|
|
pageSize?: number
|
|
}
|
|
|
|
export interface ReportTasksQuery extends ReportAnalyticsQuery {
|
|
page: number
|
|
size: number
|
|
dimension?: 'department' | 'category' | 'method' | 'campus'
|
|
groupValue?: string
|
|
}
|
|
|
|
export interface ReportExportRequest extends ReportTasksQuery {
|
|
timeDimension: 'CREATED' | 'SIGNED' | 'EXPIRED'
|
|
sort: string
|
|
direction: 'asc' | 'desc'
|
|
format: 'CSV' | 'XLSX'
|
|
columns: string[]
|
|
}
|
|
|
|
export interface ReportExportJob {
|
|
jobId: string
|
|
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
|
|
resultCount: number | null
|
|
errorMessage: string | null
|
|
downloadPath: string | null
|
|
}
|
|
|
|
function formatDateTime(value: string | null | undefined): string {
|
|
if (!value) return ''
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime()))
|
|
return value
|
|
.replace('T', ' ')
|
|
.replace(/\.\d+Z$/, '')
|
|
.replace(/Z$/, '')
|
|
const pad = (part: number) => String(part).padStart(2, '0')
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
}
|
|
|
|
function mapGroup(group: ReportGroupDto): ReportGroupRow {
|
|
const { voided, ...counts } = group
|
|
return { ...counts, void: voided }
|
|
}
|
|
|
|
function mapStatus(value: string): SigningTaskStatus {
|
|
switch (value) {
|
|
case 'WAITING_SIGN':
|
|
return 'pending'
|
|
case 'WAITING_CONFIRM':
|
|
return 'confirming'
|
|
case 'CREATED':
|
|
case 'GENERATING':
|
|
return 'signing'
|
|
case 'SIGNED':
|
|
return 'signed'
|
|
case 'EXPIRED':
|
|
return 'expired'
|
|
case 'VOIDED':
|
|
return 'void'
|
|
default:
|
|
return 'failed'
|
|
}
|
|
}
|
|
|
|
function mapMethod(value: string): SigningMethod {
|
|
return value === 'SMS' ? 'sms' : 'pad'
|
|
}
|
|
|
|
function mapTask(task: ReportTaskDto): ReportTaskRow {
|
|
const signedAt = task.signedAt ? formatDateTime(task.signedAt) : undefined
|
|
const createdAt = formatDateTime(task.createdAt)
|
|
const elapsed = task.signedAt
|
|
? Math.max(
|
|
0,
|
|
Math.round(
|
|
(new Date(task.signedAt).getTime() - new Date(task.createdAt).getTime()) / 60_000,
|
|
),
|
|
)
|
|
: undefined
|
|
return {
|
|
id: task.taskId,
|
|
patientId: task.patientDisplayId,
|
|
patientName: task.patientNameMasked,
|
|
campus: task.campusName,
|
|
department: task.departmentName,
|
|
documentName: task.templateName,
|
|
category: task.templateCategory,
|
|
method: mapMethod(task.signMethod),
|
|
status: mapStatus(task.status),
|
|
createdAt,
|
|
signedAt,
|
|
signMinutes: elapsed,
|
|
}
|
|
}
|
|
|
|
export function buildReportAnalyticsQuery(
|
|
filter: ReportFilterInput,
|
|
window: { from: string; to: string },
|
|
): ReportAnalyticsQuery {
|
|
const start = new Date(`${window.from}T00:00:00`)
|
|
const end = new Date(`${window.to}T00:00:00`)
|
|
end.setDate(end.getDate() + 1)
|
|
const query: ReportAnalyticsQuery = {
|
|
createdFrom: start.toISOString(),
|
|
createdTo: end.toISOString(),
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
|
}
|
|
if (filter.campus && filter.campus !== 'all') query.campusId = filter.campus
|
|
if (filter.department) query.departmentId = filter.department
|
|
if (filter.category) query.templateCategory = filter.category
|
|
if (filter.method !== 'all') query.signMethod = filter.method.toUpperCase() as 'PAD' | 'SMS'
|
|
return query
|
|
}
|
|
|
|
export async function getReportOptions(): Promise<ReportOptions> {
|
|
const response = await request.get<ApiResponse<ReportOptionsDto>>('/v1/reports/signing/options')
|
|
return unwrapApiResponse(response)
|
|
}
|
|
|
|
export async function getReportAnalytics(
|
|
query: ReportAnalyticsQuery,
|
|
): Promise<ReportAnalyticsDataset> {
|
|
const response = await request.get<ApiResponse<ReportAnalyticsDto>>(
|
|
'/v1/reports/signing/analytics',
|
|
{ params: query },
|
|
)
|
|
const dto = unwrapApiResponse(response)
|
|
const { voided, ...statusCounts } = dto.statusCounts
|
|
return {
|
|
timezone: dto.timezone,
|
|
generatedAt: formatDateTime(dto.generatedAt),
|
|
fromDate: dto.fromDate,
|
|
toDate: dto.toDate,
|
|
statusCounts: { ...statusCounts, void: voided },
|
|
trend: dto.trend.map((point) => ({
|
|
key: point.date,
|
|
label: `${Number(point.date.slice(5, 7))}/${Number(point.date.slice(8, 10))}`,
|
|
created: point.created,
|
|
signed: point.signed,
|
|
})),
|
|
departments: dto.departments.map(mapGroup),
|
|
categories: dto.categories.map(mapGroup),
|
|
methods: dto.methods.map(mapGroup),
|
|
campuses: dto.campuses.map(mapGroup),
|
|
documents: dto.documents.map(mapGroup),
|
|
timeliness: {
|
|
averageMinutes: dto.timeliness.averageMinutes,
|
|
within24hRate: dto.timeliness.within24hRate,
|
|
overdueRate: dto.timeliness.overdueRate,
|
|
expiredTaskCount: dto.timeliness.expiredTaskCount,
|
|
signedSampleCount: dto.timeliness.signedSampleCount,
|
|
departmentAverages: dto.timeliness.departmentAverages.map((row) => ({
|
|
label: row.label,
|
|
value: row.averageMinutes,
|
|
})),
|
|
dailyAverages: dto.timeliness.dailyAverages.map((row) => ({
|
|
date: row.label,
|
|
averageMinutes: row.averageMinutes,
|
|
sampleCount: row.sampleCount,
|
|
})),
|
|
overdueTaskCount: dto.timeliness.overdueTaskCount,
|
|
overdueTasks: dto.timeliness.overdueTasks.map((row) => ({
|
|
id: row.taskId,
|
|
patientId: row.patientDisplayId,
|
|
patientName: row.patientNameMasked,
|
|
department: row.department,
|
|
documentName: row.documentName,
|
|
createdAt: formatDateTime(row.createdAt),
|
|
signedAt: formatDateTime(row.signedAt),
|
|
signMinutes: row.elapsedMinutes,
|
|
})),
|
|
},
|
|
patientStructure: {
|
|
age: dto.patientStructure.age,
|
|
sex: dto.patientStructure.sex,
|
|
visitType: dto.patientStructure.visitType,
|
|
campus: dto.patientStructure.campus,
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function getReportAnalyticsTasks(
|
|
query: ReportTasksQuery,
|
|
): Promise<PageResult<ReportTaskRow>> {
|
|
const response = await request.get<ApiResponse<BackendPage<ReportTaskDto>>>(
|
|
'/v1/reports/signing/analytics/tasks',
|
|
{
|
|
params: query,
|
|
},
|
|
)
|
|
const page = unwrapApiResponse(response)
|
|
return {
|
|
records: (page.records ?? page.items ?? []).map(mapTask),
|
|
total: page.total ?? 0,
|
|
page: page.page ?? query.page,
|
|
pageSize: page.pageSize ?? page.size ?? query.size,
|
|
}
|
|
}
|
|
|
|
export function createReportExportJob(input: ReportExportRequest): Promise<ReportExportJob> {
|
|
return request
|
|
.post<ApiResponse<ReportExportJob>>('/v1/reports/signing/export-jobs', {
|
|
...input,
|
|
page: 1,
|
|
size: 200,
|
|
timeDimension: 'CREATED',
|
|
sort: 'createdAt',
|
|
direction: 'desc',
|
|
})
|
|
.then(unwrapApiResponse)
|
|
}
|
|
|
|
export function getReportExportJob(jobId: string): Promise<ReportExportJob> {
|
|
return request
|
|
.get<ApiResponse<ReportExportJob>>(
|
|
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`,
|
|
)
|
|
.then(unwrapApiResponse)
|
|
}
|
|
|
|
export async function downloadReportExport(path: string, filename: string): Promise<void> {
|
|
if (!/^\/api\/v1\/reports\/signing\/export-jobs\/[0-9a-f-]{36}\/download$/i.test(path)) {
|
|
throw new Error('后端返回的导出下载地址无效')
|
|
}
|
|
const apiPath = path.replace(/^\/api(?=\/)/, '')
|
|
const blob = await request.get<Blob>(apiPath, { responseType: 'blob' })
|
|
const url = URL.createObjectURL(blob)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.download = filename
|
|
anchor.click()
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
}
|