Files
onboard/resources/js/Pages/Analytics/Builder.vue

370 lines
14 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 { 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(--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(--border-color, rgba(255,255,255,.12));
border-radius: 8px;
background: var(--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>