UI коструктора отчетов
This commit is contained in:
370
resources/js/Pages/Analytics/Builder.vue
Normal file
370
resources/js/Pages/Analytics/Builder.vue
Normal 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>
|
||||
Reference in New Issue
Block a user