From ac581801ebdb34d5328b26b9003d3dbade4bf0d6 Mon Sep 17 00:00:00 2001 From: yelan Date: Wed, 2 Sep 2026 09:04:24 +0800 Subject: [PATCH] feat(management): align reports with signing APIs --- clinical-web/src/api/management/reports.ts | 98 ++-- clinical-web/src/api/management/types.ts | 43 +- .../reports/components/ReportDetailTable.vue | 6 + .../reports/components/ReportFilterBar.vue | 40 ++ .../src/views/management/reports/index.vue | 426 ++++++++++++++---- .../src/views/management/reports/mock.ts | 11 + .../src/views/management/reports/types.ts | 10 +- 7 files changed, 469 insertions(+), 165 deletions(-) diff --git a/clinical-web/src/api/management/reports.ts b/clinical-web/src/api/management/reports.ts index 1fb7e51..758f8ca 100644 --- a/clinical-web/src/api/management/reports.ts +++ b/clinical-web/src/api/management/reports.ts @@ -1,38 +1,76 @@ -import { request } from '@/utils/request' +import { getCampuses, getDepartments } from './organization' +import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing' +import type { WorkbenchCampus } from '@/api/workbench/types' -import type { ReportOverviewResponse, ReportQuery } from './types' +import type { ReportTaskData } from './types' -const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' +const REPORT_PAGE_SIZE = 200 -const mockOverview: ReportOverviewResponse = { - metrics: [ - { key: 'total', label: '签署总量', value: 1268, unit: '份', trend: 12.8 }, - { key: 'signed', label: '已完成', value: 1142, unit: '份', trend: 8.6 }, - { key: 'rate', label: '完成率', value: 90.1, unit: '%', trend: 3.2 }, - { key: 'average', label: '平均耗时', value: 6.4, unit: '分钟', trend: -4.5 }, - ], - trend: [ - { date: '08-21', signed: 146, rejected: 5, pending: 12 }, - { date: '08-22', signed: 158, rejected: 4, pending: 10 }, - { date: '08-23', signed: 132, rejected: 7, pending: 15 }, - { date: '08-24', signed: 176, rejected: 3, pending: 9 }, - { date: '08-25', signed: 188, rejected: 6, pending: 13 }, - { date: '08-26', signed: 172, rejected: 5, pending: 11 }, - { date: '08-27', signed: 170, rejected: 4, pending: 8 }, - ], - departments: [ - { department: '消化内科', total: 328, signed: 305, completionRate: 93.0 }, - { department: '儿科急诊', total: 286, signed: 249, completionRate: 87.1 }, - { department: '普外科', total: 264, signed: 243, completionRate: 92.0 }, - ], +export const isReportsMockEnabled = isSigningMockEnabled + +function isWorkbenchCampus(value: string): value is WorkbenchCampus { + return value === '本部院区' || value === '东院区' || value === '西院区' } -export function getReportOverview(query: ReportQuery): Promise { - if (useMockData) { - return Promise.resolve(mockOverview) +function buildSigningApiOptions(data: ReportTaskData) { + return { + templates: data.templates, + campusNames: Object.fromEntries( + data.campuses + .filter((campus) => isWorkbenchCampus(campus.name)) + .map((campus) => [campus.id, campus.name]), + ) as Record, + } +} + +async function getAllReportTasks(data: ReportTaskData) { + const options = buildSigningApiOptions(data) + const query = { + page: 1, + pageSize: REPORT_PAGE_SIZE, + status: 'all' as const, + dateRange: 'all' as const, + campus: 'all' as const, + method: 'all' as const, + } + const firstPage = await getSigningTasks(query, options) + const records = [...firstPage.records] + const pageSize = Math.max(firstPage.pageSize, 1) + const totalPages = Math.ceil(firstPage.total / pageSize) + + for (let page = 2; page <= totalPages; page += 1) { + const nextPage = await getSigningTasks({ ...query, page }, options) + records.push(...nextPage.records) + + if (!nextPage.records.length) { + break + } } - return request.get('/management/reports/overview', { - params: query, - }) + return records +} + +/** + * 查询报表所需的全部可见任务。 + * 当前后端没有聚合报表接口,因此这里按最大页大小读取签署任务;聚合接口上线后只需替换本函数。 + */ +export async function getReportTaskData(): Promise { + if (isReportsMockEnabled) { + return { tasks: [], templates: [], campuses: [], departments: [] } + } + + const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([ + getSigningTemplates(), + getCampuses(), + getDepartments(), + ]) + const data: ReportTaskData = { + tasks: [], + templates: templatesResult.status === 'fulfilled' ? templatesResult.value : [], + campuses: campusesResult.status === 'fulfilled' ? campusesResult.value : [], + departments: departmentsResult.status === 'fulfilled' ? departmentsResult.value : [], + } + + data.tasks = await getAllReportTasks(data) + return data } diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index cd6ac41..b707c72 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -1,4 +1,5 @@ import type { PageQuery, PageResult } from '@/types/common' +import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types' export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review' @@ -260,39 +261,15 @@ export interface OrganizationResponseDto { departmentName?: string | null } -export type ReportPeriod = 'today' | 'week' | 'month' | 'year' - -export interface ReportQuery { - period: ReportPeriod - department?: string -} - -export interface ReportMetric { - key: string - label: string - value: number - unit: string - trend: number -} - -export interface ReportTrendPoint { - date: string - signed: number - rejected: number - pending: number -} - -export interface ReportDepartment { - department: string - total: number - signed: number - completionRate: number -} - -export interface ReportOverviewResponse { - metrics: ReportMetric[] - trend: ReportTrendPoint[] - departments: ReportDepartment[] +/** + * 报表当前由签署任务列表、模板和组织字典在前端聚合。 + * 后端提供聚合报表接口后,可在 reports.ts 的边界替换为对应 DTO,页面无需感知。 + */ +export interface ReportTaskData { + tasks: SigningTaskRecord[] + templates: SigningTemplate[] + campuses: CampusRecord[] + departments: DepartmentRecord[] } export type UserStatus = 'enabled' | 'disabled' diff --git a/clinical-web/src/views/management/reports/components/ReportDetailTable.vue b/clinical-web/src/views/management/reports/components/ReportDetailTable.vue index a7b5f95..81b3a34 100644 --- a/clinical-web/src/views/management/reports/components/ReportDetailTable.vue +++ b/clinical-web/src/views/management/reports/components/ReportDetailTable.vue @@ -14,6 +14,7 @@ const statusLabels: Record = { pending: '待签署', expired: '已超时', void: '已作废', + failed: '处理失败', } function getStatusLabel(row: ReportTask) { @@ -154,6 +155,11 @@ function formatTime(date: Date) { background: #eceef0; } +.status-failed { + color: var(--err); + background: #fff0f0; +} + .view-button { padding: 4px 10px; color: var(--brand); diff --git a/clinical-web/src/views/management/reports/components/ReportFilterBar.vue b/clinical-web/src/views/management/reports/components/ReportFilterBar.vue index 13d74bb..eecb86e 100644 --- a/clinical-web/src/views/management/reports/components/ReportFilterBar.vue +++ b/clinical-web/src/views/management/reports/components/ReportFilterBar.vue @@ -11,6 +11,8 @@ defineProps<{ campus: ReportCampus department: string category: ReportDocumentCategory + customFrom: string + customTo: string periodOptions: ReportSelectOption[] campusOptions: ReportSelectOption[] departmentOptions: ReportSelectOption[] @@ -22,6 +24,8 @@ const emit = defineEmits<{ 'update:campus': [value: ReportCampus] 'update:department': [value: string] 'update:category': [value: ReportDocumentCategory] + 'update:customFrom': [value: string] + 'update:customTo': [value: string] export: [] }>() @@ -85,6 +89,27 @@ function handleCategoryChange(event: Event) { + + @@ -126,6 +151,21 @@ function handleCategoryChange(event: Event) { border-radius: 6px; } +.filter-field input { + width: 132px; + padding: 7px 8px; + color: var(--ink); + font-size: 13px; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; +} + +.filter-field input:focus { + outline: none; + border-color: var(--brand); +} + .filter-field select:focus { outline: none; border-color: var(--brand); diff --git a/clinical-web/src/views/management/reports/index.vue b/clinical-web/src/views/management/reports/index.vue index 21ba40a..d4a28c1 100644 --- a/clinical-web/src/views/management/reports/index.vue +++ b/clinical-web/src/views/management/reports/index.vue @@ -3,9 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { useRouter } from 'vue-router' -import { getCampuses, getDepartments } from '@/api/management/organization' -import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing' -import type { SigningTaskRecord, SigningTemplate, WorkbenchCampus } from '@/api/workbench/types' +import { getReportTaskData, isReportsMockEnabled } from '@/api/management/reports' +import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types' import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue' import ReportDetailTable from './components/ReportDetailTable.vue' @@ -33,9 +32,13 @@ import type { ReportTrendPoint, } from './types' +const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000 + const loading = ref(true) +const errorMessage = ref('') const tasks = ref([]) const departmentOptions = ref(reportDepartmentOptions) +const campusOptions = ref[]>(reportCampusOptions) const router = useRouter() const filters = reactive({ @@ -43,53 +46,143 @@ const filters = reactive({ campus: 'all', department: 'all', category: 'all', + customFrom: '', + customTo: '', +}) + +interface ReportDateRange { + start: Date + end: Date +} + +const customDateError = computed(() => { + if (filters.period !== 'custom') { + return '' + } + + if (!filters.customFrom || !filters.customTo) { + return '请选择完整的自定义日期范围' + } + + if (filters.customFrom > filters.customTo) { + return '起始日期不能晚于结束日期' + } + + return '' +}) + +const selectedDateRange = computed(() => { + if (customDateError.value) { + return null + } + + const now = new Date() + + if (filters.period === 'custom') { + const start = parseDateInput(filters.customFrom) + const end = parseDateInput(filters.customTo, true) + + return start && end ? { start, end } : null + } + + const start = startOfDay(now) + + if (filters.period === 'month') { + start.setDate(1) + } else { + const days = filters.period === '7d' ? 7 : 14 + start.setDate(start.getDate() - days + 1) + } + + return { start, end: endOfDay(now) } }) const filteredTasks = computed(() => { - const startDate = getStartDate(filters.period) + const range = selectedDateRange.value + + if (!range) { + return [] + } return tasks.value.filter((task) => { - const matchesDate = !startDate || task.createdAt >= startDate - const matchesCampus = filters.campus === 'all' || task.campus === filters.campus - const matchesDepartment = filters.department === 'all' || task.department === filters.department - const matchesCategory = filters.category === 'all' || task.category === filters.category - - return matchesDate && matchesCampus && matchesDepartment && matchesCategory + return isDateWithin(task.createdAt, range) && matchesTaskScope(task) }) }) +const completedTasks = computed(() => { + const range = selectedDateRange.value + + if (!range) { + return [] + } + + return tasks.value.filter( + (task) => + task.status === 'signed' && + task.signedAt && + isDateWithin(task.signedAt, range) && + matchesTaskScope(task), + ) +}) + +const expiredTasks = computed(() => { + const range = selectedDateRange.value + + if (!range) { + return [] + } + + return tasks.value.filter( + (task) => + task.status === 'expired' && + task.expiredAt && + isDateWithin(task.expiredAt, range) && + matchesTaskScope(task), + ) +}) + const metrics = computed(() => { - const list = filteredTasks.value - const signedTasks = list.filter((task) => task.status === 'signed') - const completionRate = list.length ? Math.round((signedTasks.length / list.length) * 100) : 0 - const averageMinutes = signedTasks.length - ? Math.round( - signedTasks.reduce((total, task) => total + (task.durationMinutes ?? 7), 0) / - signedTasks.length, - ) - : 0 + const completed = completedTasks.value + const terminalTasks = [...completed, ...expiredTasks.value] + const onTimeCount = completed.filter(isOnTimeTask).length + const completionRate = terminalTasks.length + ? `${Math.round((onTimeCount / terminalTasks.length) * 100)}%` + : '—' + const durations = completed + .map((task) => task.durationMinutes) + .filter((value): value is number => value !== undefined && Number.isFinite(value)) + const averageMinutes = durations.length + ? Math.round(durations.reduce((total, value) => total + value, 0) / durations.length) + : '—' return [ - { key: 'total', label: '累计发起任务', value: list.length, tone: 'brand' }, - { key: 'signed', label: '签署完成', value: signedTasks.length, tone: 'ok' }, - { key: 'rate', label: '按时签署率', value: `${completionRate}%`, tone: 'info' }, + { key: 'total', label: '累计发起任务', value: filteredTasks.value.length, tone: 'brand' }, + { key: 'signed', label: '签署完成', value: completed.length, tone: 'ok' }, + { key: 'rate', label: '按时签署率', value: completionRate, tone: 'info' }, { key: 'average', label: '平均签署用时(分钟)', value: averageMinutes, tone: 'warn' }, ] }) const trendPoints = computed(() => { - const days = filters.period === '7d' ? 7 : 14 - const today = startOfDay(new Date()) - const signedTasks = filteredTasks.value.filter((task) => task.status === 'signed') + const range = selectedDateRange.value - return Array.from({ length: days }, (_, index) => { - const date = new Date(today) - date.setDate(date.getDate() - (days - index - 1)) + if (!range) { + return [] + } + + const start = startOfDay(range.start) + const end = startOfDay(range.end) + const days = Math.floor((end.getTime() - start.getTime()) / DAY_IN_MILLISECONDS) + 1 + + return Array.from({ length: Math.max(days, 0) }, (_, index) => { + const date = new Date(start) + date.setDate(date.getDate() + index) return { - date: date.toISOString(), + date: formatDateKey(date), label: `${date.getMonth() + 1}/${date.getDate()}`, - value: signedTasks.filter((task) => isSameDay(task.createdAt, date)).length, + value: completedTasks.value.filter((task) => task.signedAt && isSameDay(task.signedAt, date)) + .length, } }) }) @@ -97,7 +190,7 @@ const trendPoints = computed(() => { const departmentPoints = computed(() => { const counts = new Map() - filteredTasks.value.forEach((task) => { + completedTasks.value.forEach((task) => { counts.set(task.department, (counts.get(task.department) ?? 0) + 1) }) @@ -116,29 +209,63 @@ function startOfDay(date: Date) { return result } -function getStartDate(period: ReportPeriodOption) { - if (period === 'custom') { - return null - } - - if (period === 'month') { - const result = startOfDay(new Date()) - result.setDate(1) - return result - } - - const days = period === '7d' ? 7 : 14 - const result = startOfDay(new Date()) - result.setDate(result.getDate() - days + 1) +function endOfDay(date: Date) { + const result = new Date(date) + result.setHours(23, 59, 59, 999) return result } +function parseDateInput(value: string, end = false) { + if (!value) { + return null + } + + const date = new Date(`${value}T${end ? '23:59:59.999' : '00:00:00'}`) + return Number.isNaN(date.getTime()) ? null : date +} + +function isDateWithin(value: Date | undefined, range: ReportDateRange) { + if (!value) { + return false + } + + const timestamp = value.getTime() + return timestamp >= range.start.getTime() && timestamp <= range.end.getTime() +} + function isSameDay(left: Date, right: Date) { - return startOfDay(left).getTime() === startOfDay(right).getTime() + return formatDateKey(left) === formatDateKey(right) +} + +function formatDateKey(date: Date) { + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +function matchesTaskScope(task: ReportTask) { + const matchesCampus = filters.campus === 'all' || task.campus === filters.campus + const matchesDepartment = filters.department === 'all' || task.department === filters.department + const matchesCategory = filters.category === 'all' || task.category === filters.category + + return matchesCampus && matchesDepartment && matchesCategory +} + +function isOnTimeTask(task: ReportTask) { + return Boolean( + task.signedAt && task.expiredAt && task.signedAt.getTime() <= task.expiredAt.getTime(), + ) } function updatePeriod(value: ReportPeriodOption) { filters.period = value + + if (value === 'custom' && (!filters.customFrom || !filters.customTo)) { + const end = new Date() + const start = new Date(end) + start.setDate(start.getDate() - 13) + filters.customFrom = formatDateKey(start) + filters.customTo = formatDateKey(end) + } } function updateCampus(value: ReportCampus) { @@ -153,9 +280,27 @@ function updateCategory(value: ReportDocumentCategory) { filters.category = value } +function updateCustomFrom(value: string) { + filters.customFrom = value +} + +function updateCustomTo(value: string) { + filters.customTo = value +} + function handleExport() { const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"` - const header = ['任务号', '文档', '科室', '院区', '方式', '状态', '发起时间'] + const header = [ + '任务号', + '文档', + '科室', + '院区', + '方式', + '状态', + '发起时间', + '签署完成时间', + '过期时间', + ] const rows = filteredTasks.value.map((task) => [ task.id, task.documentName, @@ -164,14 +309,18 @@ function handleExport() { task.method === 'pad' ? '手写板' : '短信', getReportStatusLabel(task.status), formatExportDate(task.createdAt), + formatExportDate(task.signedAt), + formatExportDate(task.expiredAt), ]) const csv = `\uFEFF${[header, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n')}` const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `医签通签署报表-${new Date().toISOString().slice(0, 10)}.csv` + link.download = `医签通签署报表-${formatDateKey(new Date())}.csv` + document.body.append(link) link.click() + link.remove() window.setTimeout(() => URL.revokeObjectURL(url), 0) ElMessage.success('报表已导出') } @@ -186,12 +335,17 @@ function getReportStatusLabel(status: ReportTaskStatus) { pending: '待签署', expired: '已超时', void: '已作废', + failed: '处理失败', } return labels[status] } -function formatExportDate(value: Date) { +function formatExportDate(value: Date | undefined) { + if (!value) { + return '' + } + const pad = (part: number) => String(part).padStart(2, '0') return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad( value.getHours(), @@ -215,20 +369,44 @@ function mapCategory( return 'consent' } -function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): ReportTask { +function parseApiDate(value: string | undefined) { + if (!value) { + return undefined + } + + const date = new Date(value.includes('T') ? value : value.replace(' ', 'T')) + return Number.isNaN(date.getTime()) ? undefined : date +} + +function mapReportTask( + task: SigningTaskRecord, + templates: SigningTemplate[], + campusNames: Record, +): ReportTask | null { const template = templates.find( (item) => item.versionId === task.templateVersionId || item.id === task.documentId, ) - const createdAt = new Date((task.createdAt ?? task.updatedAt).replace(' ', 'T')) - const normalizedCreatedAt = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt + const createdAt = parseApiDate(task.createdAt) + + if (!createdAt) { + return null + } + + const signedAt = parseApiDate(task.signedAt) + const expiredAt = parseApiDate(task.expiresAt) + const durationMinutes = signedAt + ? Math.max(0, Math.round((signedAt.getTime() - createdAt.getTime()) / 60_000)) + : undefined const status: ReportTask['status'] = task.status === 'signed' ? 'signed' : task.status === 'expired' ? 'expired' - : task.status === 'void' || task.status === 'failed' + : task.status === 'void' ? 'void' - : 'pending' + : task.status === 'failed' + ? 'failed' + : 'pending' return { id: task.id, @@ -236,74 +414,75 @@ function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): R patientId: task.patientId, documentName: task.documentName, department: task.department, - campus: task.campus as Exclude, + campus: task.campusId ? (campusNames[task.campusId] ?? task.campus) : task.campus, + campusId: task.campusId, category: mapCategory(template?.category ?? '', task.documentName), method: task.method, status, - createdAt: normalizedCreatedAt, + createdAt, + signedAt, + expiredAt, + durationMinutes, } } -function isWorkbenchCampus(value: string): value is WorkbenchCampus { - return value === '本部院区' || value === '东院区' || value === '西院区' +function getDepartmentOptions( + departments: Array<{ name: string }>, + reportTaskRecords: ReportTask[], +) { + const names = new Set(departments.map((department) => department.name)) + reportTaskRecords.forEach((task) => names.add(task.department)) + + return [ + { value: 'all', label: '全部科室' }, + ...[...names] + .filter((name) => name && name !== '未指定科室') + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map((name) => ({ value: name, label: name })), + ] +} + +function getCampusOptions(campuses: Array<{ name: string }>) { + const names = [...new Set(campuses.map((campus) => campus.name).filter(Boolean))] + + return names.length + ? [ + { value: 'all' as ReportCampus, label: '全部院区' }, + ...names.map((name) => ({ value: name as ReportCampus, label: name })), + ] + : reportCampusOptions } async function loadReports() { loading.value = true + errorMessage.value = '' try { - if (isSigningMockEnabled) { + if (isReportsMockEnabled) { tasks.value = reportTasks.map((task) => ({ ...task, createdAt: new Date(task.createdAt), + signedAt: task.signedAt ? new Date(task.signedAt) : undefined, + expiredAt: task.expiredAt ? new Date(task.expiredAt) : undefined, })) departmentOptions.value = reportDepartmentOptions + campusOptions.value = reportCampusOptions return } - const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([ - getSigningTemplates(), - getCampuses(), - getDepartments(), - ]) - const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : [] - const campusNames: Record = - campusesResult.status === 'fulfilled' - ? (Object.fromEntries( - campusesResult.value - .filter((campus) => isWorkbenchCampus(campus.name)) - .map((campus) => [campus.id, campus.name]), - ) as Record) - : {} - const taskResponse = await getSigningTasks( - { - page: 1, - pageSize: 200, - status: 'all', - dateRange: 'all', - campus: 'all', - method: 'all', - }, - { templates, campusNames }, - ) - tasks.value = taskResponse.records.map((task) => mapReportTask(task, templates)) + const data = await getReportTaskData() + const campusNames = Object.fromEntries(data.campuses.map((campus) => [campus.id, campus.name])) + const reportTaskRecords = data.tasks + .map((task) => mapReportTask(task, data.templates, campusNames)) + .filter((task): task is ReportTask => task !== null) - const departmentNames = new Set( - departmentsResult.status === 'fulfilled' - ? departmentsResult.value.map((department) => department.name) - : [], - ) - tasks.value.forEach((task) => departmentNames.add(task.department)) - departmentOptions.value = [ - { value: 'all', label: '全部科室' }, - ...[...departmentNames] - .filter((name) => name && name !== '未指定科室') - .sort((left, right) => left.localeCompare(right, 'zh-CN')) - .map((name) => ({ value: name, label: name })), - ] + tasks.value = reportTaskRecords + departmentOptions.value = getDepartmentOptions(data.departments, reportTaskRecords) + campusOptions.value = getCampusOptions(data.campuses) } catch { tasks.value = [] - ElMessage.error('报表数据加载失败,请稍后重试') + errorMessage.value = '报表数据加载失败,请稍后重试' + ElMessage.error(errorMessage.value) } finally { loading.value = false } @@ -319,21 +498,32 @@ onMounted(loadReports) :campus="filters.campus" :department="filters.department" :category="filters.category" + :custom-from="filters.customFrom" + :custom-to="filters.customTo" :period-options="reportPeriodOptions" - :campus-options="reportCampusOptions" + :campus-options="campusOptions" :department-options="departmentOptions" :category-options="reportCategoryOptions" @update:period="updatePeriod" @update:campus="updateCampus" @update:department="updateDepartment" @update:category="updateCategory" + @update:custom-from="updateCustomFrom" + @update:custom-to="updateCustomTo" @export="handleExport" /> + +
+ +