Модуль отчетов

This commit is contained in:
brusnitsyn
2026-06-21 23:40:55 +09:00
parent f163b95663
commit bd2cc24b98
27 changed files with 2781 additions and 3 deletions

View File

@@ -3,7 +3,7 @@ import AppLayout from "../../Layouts/AppLayout.vue"
import { useAuthStore } from "../../Stores/auth.js"
import { NEl, NFlex, NText, NTag, NAvatar } from 'naive-ui'
import ActionTile from "../../Components/ActionTile.vue"
import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh } from "vue-icons-plus/tb"
import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh, TbReportAnalytics } from "vue-icons-plus/tb"
import { Link } from "@inertiajs/vue3"
import { computed } from "vue"
import { useThemeVars } from "naive-ui"
@@ -85,6 +85,13 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
:tag="Link"
href="/admin/replication"
/>
<ActionTile
:icon="TbReportAnalytics"
title="Шаблоны отчётов"
description="Конструктор пользовательских отчётов"
:tag="Link"
href="/admin/report-templates"
/>
</div>
<!-- Назад -->

View File

@@ -0,0 +1,92 @@
<script setup>
import { NSelect, NInput, NTransfer, NDynamicInput, NFlex, NText, NEmpty, NFormItem, NForm } from 'naive-ui'
import { computed } from 'vue'
const props = defineProps({
section: { type: Object, required: true },
sources: { type: Array, default: () => [] },
})
const sourceOptions = computed(() => props.sources.map(s => ({ label: s.label, value: s.key })))
const selectedSource = computed(() => props.sources.find(s => s.key === props.section.source) ?? null)
const columnTransferOptions = computed(() => Object.entries(selectedSource.value?.columns ?? {})
.map(([value, label]) => ({ label, value })))
const filterableFieldOptions = computed(() => Object.entries(selectedSource.value?.filterableFields ?? {})
.map(([value, def]) => ({ label: def.label, value })))
const filterValueOptions = (fieldKey) => {
const options = selectedSource.value?.filterableFields?.[fieldKey]?.options
return options ? Object.entries(options).map(([value, label]) => ({ label, value })) : null
}
const createFilter = () => ({
field: filterableFieldOptions.value[0]?.value ?? null,
value: null,
})
const onSourceChange = () => {
// Колонки и фильтры принадлежат конкретному источнику — при смене источника они теряют смысл
props.section.columns = []
props.section.filters = []
}
</script>
<template>
<NFlex vertical :size="12" style="width: 100%;">
<NForm label-placement="top">
<NFlex :size="12" :wrap="true">
<NFormItem label="Источник данных" style="min-width: 240px; margin-bottom: 0;">
<NSelect v-model:value="section.source" :options="sourceOptions" @update:value="onSourceChange" />
</NFormItem>
<NFormItem label="Заголовок секции" style="min-width: 240px; flex: 1; margin-bottom: 0;">
<NInput v-model:value="section.title" :placeholder="selectedSource?.label" />
</NFormItem>
</NFlex>
</NForm>
<div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 6px;">Колонки</NText>
<NTransfer
v-model:value="section.columns"
:options="columnTransferOptions"
source-filterable
source-title="Доступные колонки"
target-title="Колонки секции (в этом порядке)"
style="height: 280px;"
/>
</div>
<div v-if="filterableFieldOptions.length">
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 6px;">Фильтры (необязательно)</NText>
<NDynamicInput v-model:value="section.filters" :on-create="createFilter">
<template #default="{ value }">
<NFlex align="center" :size="8" style="flex: 1;">
<NSelect
v-model:value="value.field"
:options="filterableFieldOptions"
style="width: 220px;"
placeholder="Поле"
/>
<NSelect
v-if="filterValueOptions(value.field)"
v-model:value="value.value"
:options="filterValueOptions(value.field)"
style="width: 220px;"
placeholder="Значение"
/>
<NInput
v-else
v-model:value="value.value"
style="width: 220px;"
placeholder="Значение"
/>
</NFlex>
</template>
</NDynamicInput>
<NEmpty v-if="!section.filters.length" description="Без фильтров — попадут все записи" size="small" style="padding: 8px 0;" />
</div>
</NFlex>
</template>

View File

@@ -0,0 +1,135 @@
<script setup>
import {
NButton, NFlex, NForm, NFormItem, NInput, NSelect,
NText, NDynamicInput, NAlert, NTag,
} from 'naive-ui'
import {
TbReportAnalytics, TbLayoutDashboard,
} from 'vue-icons-plus/tb'
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 SectionEditor from './Components/SectionEditor.vue'
import { useForm, Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const props = defineProps({
template: { type: Object, default: null },
sources: { type: Array, default: () => [] },
})
const page = usePage()
const flash = computed(() => page.props.flash ?? {})
const isEdit = computed(() => !!props.template)
const createSection = () => ({
source: props.sources[0]?.key ?? null,
title: '',
columns: [],
filters: [],
})
const form = useForm({
name: props.template?.name ?? '',
sections: props.template?.sections?.length ? props.template.sections : [createSection()],
required_permissions: props.template?.requiredPermissions ?? [],
})
const visibilityOptions = [
{ label: 'Дежурный врач', value: 'report.view' },
{ label: 'Старшая медсестра', value: 'nurse.report.view' },
]
const submit = () => {
if (isEdit.value) {
form.put(`/admin/report-templates/${props.template.id}`)
} else {
form.post('/admin/report-templates/new')
}
}
</script>
<template>
<AppLayout>
<AppContainer>
<NAlert v-if="flash.success" type="success" closable style="margin-bottom: 4px;">
{{ flash.success }}
</NAlert>
<PageBanner
:title="isEdit ? template.name : 'Новый шаблон отчёта'"
:icon="TbReportAnalytics"
:color="isEdit ? null : 'default'"
:breadcrumbs="[
{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link },
{ label: 'Шаблоны отчётов', href: '/admin/report-templates', icon: TbReportAnalytics, tag: Link },
]"
>
<template #meta>
<NText depth="3" style="font-size: 13px;">
{{ isEdit ? 'Редактирование шаблона' : 'Название → секции с данными → кому виден' }}
</NText>
</template>
<template #actions>
<NButton :tag="Link" href="/admin/report-templates">Отмена</NButton>
<NButton type="primary" :loading="form.processing" @click="submit">
{{ isEdit ? 'Сохранить' : 'Создать шаблон' }}
</NButton>
</template>
</PageBanner>
<NFlex vertical :size="16">
<SectionCard title="1 · Название и видимость">
<NFlex :size="16" :wrap="true">
<NForm label-placement="top" style="flex: 2; min-width: 280px;">
<NFormItem label="Название отчёта" :feedback="form.errors.name" :validation-status="form.errors.name ? 'error' : undefined" style="margin-bottom: 0;">
<NInput v-model:value="form.name" placeholder="Например: Сводка по отделению" />
</NFormItem>
</NForm>
<NForm label-placement="top" style="flex: 1; min-width: 280px;">
<NFormItem label="Кому виден отчёт" style="margin-bottom: 0;">
<NSelect
v-model:value="form.required_permissions"
:options="visibilityOptions"
multiple
placeholder="Все с доступом к отчётам"
clearable
/>
</NFormItem>
</NForm>
</NFlex>
<NText depth="3" style="font-size: 12px; display: block; margin-top: 4px;">
Ничего не выбрано отчёт увидят все, у кого есть доступ к разделу «Отчёты»
</NText>
</SectionCard>
<SectionCard title="2 · Секции с данными">
<NText v-if="form.errors.sections" type="error" style="display: block; margin-bottom: 8px; font-size: 12px;">
{{ form.errors.sections }}
</NText>
<NDynamicInput
v-model:value="form.sections"
:on-create="createSection"
item-style="margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--n-border-color, rgba(255,255,255,.08));"
>
<template #default="{ index, value }">
<NFlex vertical :size="8" style="width: 100%;">
<NTag size="small" round :bordered="false">Секция {{ index + 1 }}</NTag>
<SectionEditor :section="value" :sources="sources" />
</NFlex>
</template>
<template #create-button-default>
Добавить секцию
</template>
</NDynamicInput>
</SectionCard>
</NFlex>
</AppContainer>
</AppLayout>
</template>

View File

@@ -0,0 +1,135 @@
<script setup>
import {
NFlex, NButton, NDataTable, NTag,
NText, NIcon, NAlert, NPopconfirm, NEmpty,
} from 'naive-ui'
import {
TbReportAnalytics, TbPlus, TbPencil, TbTrash, TbLayoutDashboard,
} from 'vue-icons-plus/tb'
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 { Link, router, usePage } from '@inertiajs/vue3'
import { computed, h } from 'vue'
const props = defineProps({
templates: { type: Array, default: () => [] },
})
const page = usePage()
const flash = computed(() => page.props.flash ?? {})
const visibilityLabels = {
'report.view': 'Дежурный врач',
'nurse.report.view': 'Старшая медсестра',
}
const removeTemplate = (template) => {
router.delete(`/admin/report-templates/${template.id}`, { preserveScroll: true })
}
const columns = computed(() => [
{
key: 'name',
title: 'Название',
render: (row) => h(NFlex, { vertical: true, size: 2 }, () => [
h(NText, { style: 'font-weight: 500; font-size: 13px;' }, () => row.name),
h(NText, { depth: 3, style: 'font-size: 12px;' }, () => row.sourceLabels.join(', ')),
])
},
{
key: 'sectionsCount',
title: 'Секций',
width: 90,
render: (row) => h(NTag, { size: 'small', round: true, bordered: false }, () => `${row.sectionsCount}`)
},
{
key: 'requiredPermissions',
title: 'Видимость',
width: 240,
render: (row) => row.requiredPermissions.length
? h(NFlex, { size: 4 }, () => row.requiredPermissions.map((p) => h(NTag, { size: 'small', round: true, bordered: false, type: 'info', key: p }, () => visibilityLabels[p] ?? p)))
: h(NTag, { size: 'small', round: true, bordered: false }, () => 'Все с доступом к отчётам')
},
{
key: 'creator',
title: 'Автор',
width: 180,
render: (row) => row.creator ?? '—'
},
{
key: 'actions',
title: '',
width: 90,
align: 'center',
render: (row) => h(NFlex, { justify: 'center', size: 4 }, () => [
h(NButton, {
text: true, size: 'small', tag: Link,
href: `/admin/report-templates/${row.id}`,
title: 'Редактировать',
}, { icon: () => h(NIcon, { size: 18 }, () => h(TbPencil)) }),
h(NPopconfirm, {
onPositiveClick: () => removeTemplate(row),
}, {
trigger: () => h(NButton, {
text: true, size: 'small', title: 'Удалить',
}, { icon: () => h(NIcon, { size: 18 }, () => h(TbTrash)) }),
default: () => 'Удалить шаблон?',
}),
])
},
])
</script>
<template>
<AppLayout>
<AppContainer>
<NAlert v-if="flash.success" type="success" closable style="margin-bottom: 4px;">
{{ flash.success }}
</NAlert>
<PageBanner
title="Шаблоны отчётов"
:icon="TbReportAnalytics"
:breadcrumbs="[{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link }]"
>
<template #meta>
<NText depth="3" style="font-size: 13px;">
{{ templates.length ? `${templates.length} ${templates.length === 1 ? 'шаблон' : 'шаблонов'}` : 'Конструктор пользовательских отчётов' }}
</NText>
</template>
<template #actions>
<NButton type="primary" :tag="Link" href="/admin/report-templates/new">
<template #icon><NIcon><TbPlus /></NIcon></template>
Новый шаблон
</NButton>
</template>
</PageBanner>
<SectionCard :icon="TbReportAnalytics" title="Шаблоны" no-padding style="margin-top: 4px;">
<NEmpty
v-if="!templates.length"
description="Пока нет ни одного шаблона"
style="padding: 32px 24px;"
>
<template #extra>
<NButton size="small" type="primary" :tag="Link" href="/admin/report-templates/new">
<template #icon><NIcon><TbPlus /></NIcon></template>
Создать первый шаблон
</NButton>
</template>
</NEmpty>
<NDataTable
v-else
:columns="columns"
:data="templates"
:bordered="false"
size="small"
/>
</SectionCard>
</AppContainer>
</AppLayout>
</template>

View File

@@ -7,7 +7,7 @@ import { computed, ref } from "vue"
import { format, getHours } from "date-fns"
import { ru } from "date-fns/locale"
import { useNow } from "@vueuse/core"
import { TbArticle, TbChartTreemap, TbDoorExit, TbUserCog, TbStethoscope } from "vue-icons-plus/tb"
import { TbArticle, TbChartTreemap, TbDoorExit, TbUserCog, TbStethoscope, TbReportMedical } from "vue-icons-plus/tb"
import SelectUserModal from "./Report/Components/SelectUserModal.vue"
import { Link, router } from "@inertiajs/vue3"
import { useServerTime } from "../Composables/useServerTime.js"
@@ -154,6 +154,14 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
:tag="Link"
href="/statistic"
/>
<ActionTile
v-if="authStore.hasPermission('report.view') || authStore.hasPermission('nurse.report.view')"
:icon="TbReportMedical"
title="Отчёты"
description="Готовые отчёты, Excel и PDF"
:tag="Link"
href="/reports"
/>
<ActionTile
v-if="authStore.hasPermission('users.manage')"
:icon="TbUserCog"

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