Модуль отчетов
This commit is contained in:
306
resources/js/Pages/Reports/Index.vue
Normal file
306
resources/js/Pages/Reports/Index.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { NSelect, NButton, NDataTable, NEmpty, NFlex, NText, NTabs, NTabPane, NBadge, NDivider, NGrid, NGi } from 'naive-ui'
|
||||
import { router, Link } from '@inertiajs/vue3'
|
||||
import AppLayout from '../../Layouts/AppLayout.vue'
|
||||
import AppContainer from '../../Components/AppContainer.vue'
|
||||
import SectionCard from '../../Components/SectionCard.vue'
|
||||
import PageBanner from '../../Components/PageBanner.vue'
|
||||
import ActionTile from '../../Components/ActionTile.vue'
|
||||
import DatePickerQuery from '../../Components/DatePickerQuery.vue'
|
||||
import { useAuthStore } from '../../Stores/auth.js'
|
||||
import { TbReportMedical, TbBuildingHospital, TbSettings, TbArrowLeft } from 'vue-icons-plus/tb'
|
||||
import { TbFileSpreadsheet, TbFileTypePdf } from 'vue-icons-plus/tb'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageTemplates = computed(() => authStore.isAdmin || authStore.isChiefDoctor || authStore.isDeputyChief)
|
||||
|
||||
const props = defineProps({
|
||||
reportTypes: { type: Array, default: () => [] },
|
||||
departments: { type: Array, default: () => [] },
|
||||
selectedType: { type: String, default: null },
|
||||
selectedDepartmentId: { type: [Number, String], default: null },
|
||||
isHeadOrAdmin: { type: Boolean, default: false },
|
||||
date: { type: [Number, Array], default: () => [] },
|
||||
payload: { type: Object, default: null },
|
||||
})
|
||||
|
||||
// Локальный выбор отчёта до того, как параметры подтверждены и отчёт сформирован на сервере.
|
||||
const pendingType = ref(props.selectedType)
|
||||
const dateModel = ref(props.date)
|
||||
const departmentModel = ref(props.selectedDepartmentId)
|
||||
const downloading = ref(null)
|
||||
|
||||
// list — выбираем отчёт из каталога; params — задаём период/отделение перед формированием; result — отчёт построен
|
||||
const stage = computed(() => {
|
||||
if (props.payload) return 'result'
|
||||
if (pendingType.value) return 'params'
|
||||
return 'list'
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => props.reportTypes.map(t => ({ label: t.label, value: t.code })))
|
||||
const departmentOptions = computed(() => props.departments.map(d => ({ label: d.name, value: d.id })))
|
||||
|
||||
const selectReport = (code) => {
|
||||
pendingType.value = code
|
||||
}
|
||||
|
||||
const backToList = () => {
|
||||
pendingType.value = null
|
||||
if (props.payload) {
|
||||
router.get('/reports', {}, { preserveState: false })
|
||||
}
|
||||
}
|
||||
|
||||
const generate = () => {
|
||||
router.get('/reports', {
|
||||
type: pendingType.value,
|
||||
departmentId: departmentModel.value,
|
||||
startAt: dateModel.value?.[0],
|
||||
endAt: dateModel.value?.[1],
|
||||
})
|
||||
}
|
||||
|
||||
const onTypeChange = (value) => {
|
||||
router.reload({ data: { type: value } })
|
||||
}
|
||||
|
||||
const onDepartmentChange = (value) => {
|
||||
router.reload({ data: { departmentId: value } })
|
||||
}
|
||||
|
||||
const metaLine = computed(() => Object.values(props.payload?.meta ?? {}).join(' · '))
|
||||
|
||||
// Раздел с одной строкой коротких значений (показатели смены) удобнее показать
|
||||
// как карточки KPI, а не как таблицу из одной строки.
|
||||
const isKpiSection = (section) => {
|
||||
if (section.rows.length !== 1) return false
|
||||
const row = section.rows[0]
|
||||
return Object.keys(section.columns).every((key) => String(row[key] ?? '').length <= 40)
|
||||
}
|
||||
|
||||
const kpiSections = computed(() => props.payload?.sections.filter(isKpiSection) ?? [])
|
||||
const tableSections = computed(() => props.payload?.sections.filter((s) => !isKpiSection(s)) ?? [])
|
||||
|
||||
const kpiCards = computed(() => kpiSections.value.flatMap((section) => {
|
||||
const row = section.rows[0]
|
||||
return Object.entries(section.columns).map(([key, label]) => ({
|
||||
sectionTitle: section.title,
|
||||
label,
|
||||
value: row[key] ?? '—',
|
||||
}))
|
||||
}))
|
||||
|
||||
const sectionColumns = (section) => Object.entries(section.columns ?? {}).map(([key, title]) => ({ key, title }))
|
||||
|
||||
const buildExportUrl = (kind) => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return `/reports/export/${kind}?${params.toString()}`
|
||||
}
|
||||
|
||||
const download = async (kind) => {
|
||||
downloading.value = kind
|
||||
try {
|
||||
const response = await fetch(buildExportUrl(kind), { method: 'GET' })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки файла')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${props.payload?.title || 'report'}.${kind === 'excel' ? 'xlsx' : 'pdf'}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
downloading.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<AppContainer>
|
||||
|
||||
<PageBanner
|
||||
:title="stage === 'result' ? payload.title : 'Отчёты'"
|
||||
:icon="TbReportMedical"
|
||||
:breadcrumbs="[{ label: 'Главная', href: '/', tag: Link }]"
|
||||
>
|
||||
<template #meta>
|
||||
<NText v-if="stage === 'result'" depth="3" style="font-size: 13px;">{{ metaLine }}</NText>
|
||||
<NText v-else depth="3" style="font-size: 13px;">Выберите готовый отчёт или шаблон, чтобы сформировать его</NText>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NButton
|
||||
v-if="canManageTemplates"
|
||||
:tag="Link"
|
||||
href="/admin/report-templates"
|
||||
>
|
||||
<template #icon><TbSettings /></template>
|
||||
Шаблоны отчётов
|
||||
</NButton>
|
||||
<template v-if="stage === 'result'">
|
||||
<NButton
|
||||
:loading="downloading === 'excel'"
|
||||
@click="download('excel')"
|
||||
>
|
||||
<template #icon><TbFileSpreadsheet /></template>
|
||||
Excel
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="downloading === 'pdf'"
|
||||
@click="download('pdf')"
|
||||
>
|
||||
<template #icon><TbFileTypePdf /></template>
|
||||
PDF
|
||||
</NButton>
|
||||
</template>
|
||||
</template>
|
||||
</PageBanner>
|
||||
|
||||
<!-- Стадия 1: каталог отчётов -->
|
||||
<div v-if="stage === 'list'" class="reports-grid">
|
||||
<ActionTile
|
||||
v-for="type in reportTypes"
|
||||
:key="type.code"
|
||||
:icon="TbReportMedical"
|
||||
:title="type.label"
|
||||
:description="type.audience"
|
||||
@click="selectReport(type.code)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Стадия 2: параметры перед формированием -->
|
||||
<SectionCard v-else-if="stage === 'params'" :title="reportTypes.find(t => t.code === pendingType)?.label" :icon="TbReportMedical">
|
||||
<NFlex align="center" :size="16" :wrap="true">
|
||||
<NSelect
|
||||
v-if="departmentOptions.length > 1"
|
||||
v-model:value="departmentModel"
|
||||
:options="departmentOptions"
|
||||
style="width: 240px;"
|
||||
>
|
||||
<template #prefix><TbBuildingHospital /></template>
|
||||
</NSelect>
|
||||
<DatePickerQuery v-model:date="dateModel" :is-head-or-admin="isHeadOrAdmin" />
|
||||
</NFlex>
|
||||
<NFlex :size="8" style="margin-top: 16px;">
|
||||
<NButton @click="backToList">
|
||||
<template #icon><TbArrowLeft /></template>
|
||||
К списку отчётов
|
||||
</NButton>
|
||||
<NButton type="primary" @click="generate">Сформировать отчёт</NButton>
|
||||
</NFlex>
|
||||
</SectionCard>
|
||||
|
||||
<!-- Стадия 3: результат -->
|
||||
<template v-else-if="stage === 'result'">
|
||||
|
||||
<NFlex align="center" :size="12" :wrap="true" class="reports-toolbar">
|
||||
<NButton text @click="backToList">
|
||||
<template #icon><TbArrowLeft /></template>
|
||||
К списку отчётов
|
||||
</NButton>
|
||||
<NDivider vertical style="height: 24px;" />
|
||||
<NSelect
|
||||
v-if="typeOptions.length > 1"
|
||||
:value="selectedType"
|
||||
:options="typeOptions"
|
||||
style="width: 280px;"
|
||||
@update:value="onTypeChange"
|
||||
/>
|
||||
<NSelect
|
||||
v-if="departmentOptions.length > 1"
|
||||
:value="selectedDepartmentId"
|
||||
:options="departmentOptions"
|
||||
style="width: 240px;"
|
||||
@update:value="onDepartmentChange"
|
||||
>
|
||||
<template #prefix><TbBuildingHospital /></template>
|
||||
</NSelect>
|
||||
<DatePickerQuery v-model:date="dateModel" :is-head-or-admin="isHeadOrAdmin" />
|
||||
</NFlex>
|
||||
|
||||
<!-- KPI-карточки: разделы с единственной строкой коротких значений -->
|
||||
<NGrid v-if="kpiCards.length" responsive="screen" cols="2 s:3 m:4 l:6" :x-gap="10" :y-gap="10">
|
||||
<NGi v-for="card in kpiCards" :key="card.sectionTitle + card.label">
|
||||
<SectionCard :title="card.label" no-padding>
|
||||
<div class="kpi-cell">
|
||||
<div class="kpi-value">{{ card.value }}</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<!-- Детальные таблицы по разделам отчёта -->
|
||||
<SectionCard v-if="tableSections.length === 1" :title="tableSections[0].title" no-padding>
|
||||
<NEmpty v-if="!tableSections[0].rows.length" description="Нет данных за выбранный период" style="padding: 24px;" />
|
||||
<NDataTable
|
||||
v-else
|
||||
:columns="sectionColumns(tableSections[0])"
|
||||
:data="tableSections[0].rows"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<NTabs v-else-if="tableSections.length > 1" type="line" animated class="reports-tabs">
|
||||
<NTabPane v-for="section in tableSections" :key="section.title" :name="section.title">
|
||||
<template #tab>
|
||||
<NFlex align="center" :size="6">
|
||||
{{ section.title }}
|
||||
<NBadge :value="section.rows.length" :max="999" show-zero type="default" />
|
||||
</NFlex>
|
||||
</template>
|
||||
<NEmpty v-if="!section.rows.length" description="Нет данных за выбранный период" style="padding: 24px;" />
|
||||
<NDataTable
|
||||
v-else
|
||||
:columns="sectionColumns(section)"
|
||||
:data="section.rows"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
|
||||
</template>
|
||||
|
||||
</AppContainer>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reports-toolbar {
|
||||
padding: 10px 4px;
|
||||
}
|
||||
|
||||
.kpi-cell {
|
||||
position: relative;
|
||||
padding: 12px 16px 14px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.reports-tabs :deep(.n-tabs-pane-wrapper) {
|
||||
padding-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user