Files
onboard/resources/js/Pages/Reports/Index.vue
2026-06-21 23:40:55 +09:00

307 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>