370 lines
14 KiB
Vue
370 lines
14 KiB
Vue
<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>
|