UI коструктора отчетов

This commit is contained in:
brusnitsyn
2026-06-22 17:02:36 +09:00
parent bdb16dac54
commit 13dfcc3e05
11 changed files with 1664 additions and 0 deletions

View File

@@ -0,0 +1,370 @@
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { router, Link } from '@inertiajs/vue3'
import axios from 'axios'
import {
NButton, NText, NIcon, NSelect, NFlex, NSpin, NDropdown, useMessage, NEl
} from 'naive-ui'
import {
TbArrowLeft, TbPencil, TbSettings, TbDeviceFloppy, TbDownload,
TbChevronUp, TbChevronDown, TbBuildingHospital, TbReportMedical,
} from 'vue-icons-plus/tb'
import AppLayout from '../../Layouts/AppLayout.vue'
import AppContainer from '../../Components/AppContainer.vue'
import SectionCard from '../../Components/SectionCard.vue'
import DatePickerQuery from '../../Components/DatePickerQuery.vue'
import SettingsPanel from './Components/SettingsPanel.vue'
import ResultTable from './Components/ResultTable.vue'
import ReportChart from './Components/ReportChart.vue'
import EditReportModal from './Components/EditReportModal.vue'
import CustomMeasureModal from './Components/CustomMeasureModal.vue'
const props = defineProps({
document: { type: Object, default: null },
name: { type: String, default: 'Новый отчёт' },
description: { type: String, default: '' },
config: { type: Object, default: () => ({}) },
datasets: { type: Array, default: () => [] },
presets: { type: Array, default: () => [] },
categories: { type: Array, default: () => [] },
departments: { type: Array, default: () => [] },
selectedDepartmentId: { type: [Number, String], default: null },
isHeadOrAdmin: { type: Boolean, default: false },
canManage: { type: Boolean, default: false },
date: { type: Array, default: () => [] },
})
const message = useMessage()
const reportName = ref(props.name)
const reportDescription = ref(props.description)
const config = reactive({
dataset: props.config.dataset ?? null,
dimensions: props.config.dimensions ?? [],
measures: props.config.measures ?? [],
filters: props.config.filters ?? [],
mode: props.config.mode ?? 'period',
detalization: props.config.detalization ?? 'month',
chart: { metric: props.config.chart?.metric ?? null, type: props.config.chart?.type ?? 'bar' },
})
const result = ref(null)
const loading = ref(false)
const showSettings = ref(true)
const showEdit = ref(false)
const showMeasureModal = ref(false)
const chartCollapsed = ref(false)
const departmentModel = ref(props.selectedDepartmentId)
const dateModel = ref(props.date?.length ? [...props.date] : null)
const currentDataset = computed(() => props.datasets.find((d) => d.key === config.dataset) ?? null)
const departmentOptions = computed(() => props.departments.map((d) => ({ label: d.name, value: d.id })))
const isFixed = computed(() => !!currentDataset.value?.fixed)
const canGenerate = computed(() => !!config.dataset && (isFixed.value || config.measures.length > 0))
const metricOptions = computed(() => {
const ds = currentDataset.value
if (ds && config.measures.length) {
return config.measures
.map((key) => ds.measures.find((m) => m.key === key))
.filter(Boolean)
.map((m) => ({ label: m.label, value: m.key }))
}
// Фиксированный отчёт: показатели для графика берём из колонок результата
return (result.value?.columns ?? [])
.filter((c) => c.kind === 'measure')
.map((c) => ({ label: c.label, value: c.key }))
})
const baseMeasureOptions = computed(() => (currentDataset.value?.measures ?? []).map((m) => ({ label: m.label, value: m.key })))
const ensureChartMetric = () => {
if (config.chart.metric === '__all') return // спец-режим «сравнение показателей»
if (!config.chart.metric || !config.measures.includes(config.chart.metric)) {
config.chart.metric = config.measures[0] ?? null
}
}
const payload = () => ({
dataset: config.dataset,
dimensions: config.dimensions,
measures: config.measures,
filters: config.filters,
mode: config.mode,
detalization: config.detalization,
chart: config.chart,
name: reportName.value,
departmentId: departmentModel.value,
startAt: dateModel.value?.[0],
endAt: dateModel.value?.[1],
})
const generate = async () => {
if (!canGenerate.value) {
message.warning('Выберите источник и хотя бы один показатель')
return
}
ensureChartMetric()
loading.value = true
try {
const { data } = await axios.post('/reports/run', payload())
result.value = data
} catch (e) {
message.error('Не удалось сформировать отчёт')
} finally {
loading.value = false
}
}
const onChangeDataset = (key) => {
config.dataset = key
config.dimensions = []
config.measures = []
config.filters = []
config.chart = { metric: null, type: 'bar' }
result.value = null
}
const onApplyPreset = (preset) => {
const c = preset.config
config.dataset = c.dataset
config.dimensions = [...(c.dimensions ?? [])]
config.measures = [...(c.measures ?? [])]
config.filters = [...(c.filters ?? [])]
config.mode = c.mode ?? 'period'
config.detalization = c.detalization ?? 'month'
config.chart = { metric: c.chart?.metric ?? null, type: c.chart?.type ?? 'bar' }
if (preset.key !== 'blank' && reportName.value === 'Новый отчёт') reportName.value = preset.label
result.value = null
}
const onChartUpdate = (chart) => {
const metricChanged = chart.metric !== config.chart.metric
config.chart = chart
if (config.mode === 'dynamics' && metricChanged && result.value) generate()
}
const saveEdit = ({ name, description }) => {
reportName.value = name
reportDescription.value = description
}
const save = () => {
const body = {
name: reportName.value,
description: reportDescription.value,
dataset: config.dataset,
config: {
dimensions: config.dimensions,
measures: config.measures,
filters: config.filters,
mode: config.mode,
detalization: config.detalization,
chart: config.chart,
},
period: dateModel.value ? { start: dateModel.value[0], end: dateModel.value[1] } : null,
}
if (props.document?.id) {
router.put(`/reports/${props.document.id}`, body, { onSuccess: () => message.success('Отчёт сохранён') })
} else {
router.post('/reports', body)
}
}
const download = async (kind) => {
try {
const { data } = await axios.post(`/reports/export/${kind}`, payload(), { responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([data]))
const a = document.createElement('a')
a.href = url
a.download = `${reportName.value || 'report'}.${kind === 'excel' ? 'xlsx' : 'pdf'}`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (e) {
message.error('Не удалось выгрузить файл')
}
}
const exportMenu = [
{ key: 'excel', label: 'Excel (.xlsx)' },
{ key: 'pdf', label: 'PDF' },
]
onMounted(() => {
if (canGenerate.value) generate()
})
watch(() => config.dataset, ensureChartMetric)
// Смена периода/отделения — сразу пересобираем уже сформированный отчёт.
watch([dateModel, departmentModel], () => {
if (result.value) generate()
})
</script>
<template>
<AppLayout>
<AppContainer>
<div class="builder">
<div class="builder-main">
<NButton text size="small" :tag="Link" href="/reports" style="margin-bottom: 8px;">
<template #icon><TbArrowLeft /></template>
Назад
</NButton>
<div class="builder-header">
<div>
<div class="title-row">
<h1 class="report-title text-nowrap">{{ reportName }}</h1>
<NButton v-if="canManage" text @click="showEdit = true"><NIcon :size="16"><TbPencil /></NIcon></NButton>
</div>
<NText v-if="reportDescription" depth="3" style="font-size: 13px;">{{ reportDescription }}</NText>
</div>
<NFlex :size="8" align="center">
<NButton type="primary" :loading="loading" @click="generate">Сформировать отчёт</NButton>
<NDropdown trigger="click" :options="exportMenu" @select="download">
<NButton>
<template #icon><TbDownload /></template>
Скачать
</NButton>
</NDropdown>
<NButton v-if="canManage" @click="save">
<template #icon><TbDeviceFloppy /></template>
Сохранить
</NButton>
<NButton :type="showSettings ? 'primary' : 'default'" ghost @click="showSettings = !showSettings">
<template #icon><TbSettings /></template>
Настройки
</NButton>
</NFlex>
</div>
<NFlex align="center" :size="12" :wrap="true" class="builder-toolbar">
<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="true"
local
hint
/>
</NFlex>
<NSpin :show="loading">
<SectionCard no-padding title="Визуализация" style="margin-bottom: 16px;">
<div class="chart-block">
<ReportChart
v-show="!chartCollapsed"
:columns="result?.columns ?? []"
:rows="result?.rows ?? []"
:chart="config.chart"
:mode="config.mode"
:metric-options="metricOptions"
@update:chart="onChartUpdate"
/>
<div class="collapse-toggle" @click="chartCollapsed = !chartCollapsed">
<NIcon><component :is="chartCollapsed ? TbChevronDown : TbChevronUp" /></NIcon>
</div>
</div>
</SectionCard>
<SectionCard title="Данные" no-padding>
<ResultTable :columns="result?.columns ?? []" :rows="result?.rows ?? []" />
</SectionCard>
</NSpin>
</div>
<transition name="slide">
<NEl v-if="showSettings" class="builder-side">
<SettingsPanel
:config="config"
:datasets="datasets"
:presets="presets"
:categories="categories"
:can-manage="canManage"
@close="showSettings = false"
@change-dataset="onChangeDataset"
@apply-preset="onApplyPreset"
@create-measure="showMeasureModal = true"
/>
</NEl>
</transition>
</div>
<EditReportModal
v-model:show="showEdit"
:name="reportName"
:description="reportDescription"
@save="saveEdit"
/>
<CustomMeasureModal
v-model:show="showMeasureModal"
:dataset="config.dataset"
:base-measures="baseMeasureOptions"
/>
</AppContainer>
</AppLayout>
</template>
<style scoped>
:deep(.n-data-table-th) { background: transparent !important; }
:deep(.n-data-table) { background: transparent; }
:deep(.n-data-table-wrapper) { border-radius: 0; }
:deep(.n-data-table-th .n-data-table-th__title) { font-size: 12px; }
:deep(.n-data-table-td) { font-size: 13px; }
.builder { display: flex; gap: 16px; align-items: flex-start; }
.builder-main { flex: 1; min-width: 0; }
.builder-side {
width: 360px;
flex-shrink: 0;
position: sticky;
top: 12px;
border: 1px solid var(--n-border-color, rgba(255,255,255,.1));
border-radius: 14px;
background: var(--card-color);
padding: 14px;
}
.builder-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.title-row { display: flex; align-items: center; gap: 6px; }
.report-title { font-size: 22px; font-weight: 700; margin: 0; }
.builder-toolbar { margin-bottom: 16px; }
.chart-block { position: relative; padding: 16px; }
.collapse-toggle {
position: absolute;
left: 50%;
bottom: -12px;
transform: translateX(-50%);
width: 28px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--n-border-color, rgba(255,255,255,.12));
border-radius: 8px;
background: var(--n-card-color);
cursor: pointer;
}
.slide-enter-active, .slide-leave-active { transition: opacity .15s, transform .15s; }
.slide-enter-from, .slide-leave-to { opacity: 0; transform: translateX(12px); }
@media (max-width: 1100px) {
.builder { flex-direction: column; }
.builder-side { width: 100%; position: static; }
}
</style>