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,312 @@
<script setup>
import { ref, computed } from 'vue'
import {
NButton, NText, NIcon, NSelect, NScrollbar, NGrid, NGi, NAlert, NRadio, NEmpty,
} from 'naive-ui'
import {
TbX, TbChevronRight, TbCalendar, TbTrendingUp, TbLayoutGrid, TbDatabase,
TbStack2, TbChartBar, TbFilter, TbClockHour4, TbArrowLeft,
} from 'vue-icons-plus/tb'
import PickerPanel from './PickerPanel.vue'
import PresetCard from './PresetCard.vue'
const props = defineProps({
config: { type: Object, required: true },
datasets: { type: Array, default: () => [] },
presets: { type: Array, default: () => [] },
categories: { type: Array, default: () => [] },
// Шаблон и Данные доступны только администратору / глав. врачу / зам. ГВ
canManage: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'change-dataset', 'apply-preset', 'create-measure'])
const view = ref('root') // root | data | template | dimensions | measures | filters
const templateCategory = ref('Все')
const currentDataset = computed(() => props.datasets.find((d) => d.key === props.config.dataset) ?? null)
const isFixed = computed(() => !!currentDataset.value?.fixed)
const dimensions = computed(() => currentDataset.value?.dimensions ?? [])
const measures = computed(() => currentDataset.value?.measures ?? [])
const filterDefs = computed(() => currentDataset.value?.filters ?? [])
const detalizationOptions = [
{ label: 'По дням', value: 'day' },
{ label: 'По неделям', value: 'week' },
{ label: 'По месяцам', value: 'month' },
]
const filteredPresets = computed(() => props.presets.filter((p) =>
templateCategory.value === 'Все' || p.category === templateCategory.value || p.key === 'blank'
))
const selectDataset = (key) => {
if (key !== props.config.dataset) emit('change-dataset', key)
}
const applyPreset = (preset) => {
emit('apply-preset', preset)
view.value = 'root'
}
const filterValue = (key) => props.config.filters.find((f) => f.key === key)?.value ?? null
const setFilterValue = (key, value) => {
const rest = props.config.filters.filter((f) => f.key !== key)
props.config.filters = (value === null || value === '') ? rest : [...rest, { key, value }]
}
const filterOptions = (def) => (def.options ?? [])
</script>
<template>
<div class="settings">
<!-- ROOT -->
<template v-if="view === 'root'">
<div class="head">
<span class="head-title">Настройки</span>
<NButton text @click="emit('close')"><NIcon :size="18"><TbX /></NIcon></NButton>
</div>
<div v-if="canManage" class="row" @click="view = 'template'">
<NIcon :size="18" class="row-icon"><TbLayoutGrid /></NIcon>
<span class="row-label">Шаблон</span>
<span class="row-value">{{ presets.find(p => p.dataset === config.dataset && p.key !== 'blank')?.label ?? 'Без шаблона' }}</span>
<NIcon class="row-chevron"><TbChevronRight /></NIcon>
</div>
<div v-if="canManage" class="row" @click="view = 'data'">
<NIcon :size="18" class="row-icon"><TbDatabase /></NIcon>
<span class="row-label">Данные</span>
<span class="row-value">{{ currentDataset?.label ?? 'Не выбрано' }}</span>
<NIcon class="row-chevron"><TbChevronRight /></NIcon>
</div>
<NAlert v-if="isFixed" type="info" :bordered="false" style="margin-top: 8px; font-size: 12px;">
Готовый отчёт с фиксированными колонками. Выберите период и нажмите «Сформировать отчёт».
</NAlert>
<template v-if="!isFixed">
<div class="section-label">Параметры данных</div>
<div class="segment">
<button class="seg-btn" :class="{ active: config.mode === 'period' }" @click="config.mode = 'period'">
<NIcon :size="15"><TbCalendar /></NIcon> За период
</button>
<button class="seg-btn" :class="{ active: config.mode === 'dynamics' }" @click="config.mode = 'dynamics'">
<NIcon :size="15"><TbTrendingUp /></NIcon> В динамике
</button>
</div>
<div class="row" :class="{ disabled: !currentDataset }" @click="currentDataset && (view = 'dimensions')">
<NIcon :size="18" class="row-icon"><TbStack2 /></NIcon>
<span class="row-label">Группировки</span>
<span class="row-value">{{ config.dimensions.length || 'Не выбрано' }}</span>
<NIcon class="row-chevron"><TbChevronRight /></NIcon>
</div>
<div class="row" :class="{ disabled: !currentDataset }" @click="currentDataset && (view = 'measures')">
<NIcon :size="18" class="row-icon"><TbChartBar /></NIcon>
<span class="row-label">Показатели</span>
<span class="row-value">{{ config.measures.length || 'Не выбрано' }}</span>
<NIcon class="row-chevron"><TbChevronRight /></NIcon>
</div>
<div v-if="config.mode === 'dynamics'" class="row static">
<NIcon :size="18" class="row-icon"><TbClockHour4 /></NIcon>
<span class="row-label">Детализация</span>
<NSelect
:value="config.detalization"
:options="detalizationOptions"
size="small"
style="width: 150px;"
@update:value="config.detalization = $event"
/>
</div>
<div class="row" :class="{ disabled: !filterDefs.length }" @click="filterDefs.length && (view = 'filters')">
<NIcon :size="18" class="row-icon"><TbFilter /></NIcon>
<span class="row-label">Фильтры</span>
<span class="row-value">{{ config.filters.length || 'Не выбрано' }}</span>
<NIcon class="row-chevron"><TbChevronRight /></NIcon>
</div>
</template>
</template>
<!-- DATA -->
<template v-else-if="view === 'data'">
<div class="sub-head">
<NButton text size="small" @click="view = 'root'">Назад</NButton>
<NButton text @click="emit('close')"><NIcon :size="18"><TbX /></NIcon></NButton>
</div>
<div class="panel-title">Данные</div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px;">
Данные определяют группировки и показатели отчёта
</NText>
<div class="data-list">
<div
v-for="ds in datasets"
:key="ds.key"
class="data-card"
:class="{ active: ds.key === config.dataset }"
@click="selectDataset(ds.key)"
>
<div>
<div class="data-title">{{ ds.label }}</div>
<div class="data-desc">{{ ds.description }}</div>
</div>
<NRadio :checked="ds.key === config.dataset" />
</div>
</div>
<NAlert type="warning" :bordered="false" style="margin-top: 12px; font-size: 12px;">
При изменении набора данных выбранные группировки и показатели сбросятся
</NAlert>
</template>
<!-- TEMPLATE -->
<template v-else-if="view === 'template'">
<div class="sub-head">
<NButton text size="small" @click="view = 'root'">
<template #icon><TbArrowLeft /></template>
Назад
</NButton>
<NButton text @click="emit('close')"><NIcon :size="18"><TbX /></NIcon></NButton>
</div>
<div class="panel-title">Шаблон</div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 10px;">
Создавайте отчёт по популярным шаблонам
</NText>
<NSelect
v-model:value="templateCategory"
:options="categories.map(c => ({ label: c, value: c }))"
size="small"
style="margin-bottom: 12px;"
/>
<NScrollbar style="max-height: calc(100vh - 320px);" class="scrollbar-gutter-stable">
<NGrid cols="1" :y-gap="10" class="py-0.5">
<NGi v-for="preset in filteredPresets" :key="preset.key">
<PresetCard :preset="preset" @click="applyPreset(preset)" />
</NGi>
</NGrid>
</NScrollbar>
</template>
<!-- DIMENSIONS -->
<PickerPanel
v-else-if="view === 'dimensions'"
title="Группировки"
description="Выберите, какие данные отображать в таблице"
search-placeholder="Поиск по группировке"
:items="dimensions"
:model-value="config.dimensions"
@update:model-value="config.dimensions = $event"
@back="view = 'root'"
/>
<!-- MEASURES -->
<PickerPanel
v-else-if="view === 'measures'"
title="Показатели"
search-placeholder="Поиск по показателям"
allow-create
:items="measures"
:model-value="config.measures"
@update:model-value="config.measures = $event"
@back="view = 'root'"
@create="emit('create-measure')"
/>
<!-- FILTERS -->
<template v-else-if="view === 'filters'">
<div class="sub-head">
<NButton text size="small" @click="view = 'root'">
<template #icon><TbArrowLeft /></template>
Назад
</NButton>
<NButton text @click="emit('close')"><NIcon :size="18"><TbX /></NIcon></NButton>
</div>
<div class="panel-title">Фильтры</div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px;">
Выберите параметры, по которым фильтровать таблицу
</NText>
<NEmpty v-if="!filterDefs.length" description="Нет доступных фильтров" size="small" />
<div v-for="def in filterDefs" :key="def.key" class="filter-row">
<span class="row-label">{{ def.label }}</span>
<NSelect
v-if="def.type === 'select'"
:value="filterValue(def.key)"
:options="filterOptions(def)"
clearable
size="small"
placeholder="Все"
style="width: 160px;"
@update:value="setFilterValue(def.key, $event)"
/>
</div>
</template>
</div>
</template>
<style scoped>
.settings { padding: 4px 2px; background: var(--n-) }
.head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.head-title { font-size: 18px; font-weight: 600; }
.sub-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.panel-title { font-size: 18px; font-weight: 600; margin-bottom: 2px; }
.section-label { font-size: 12px; color: var(--n-text-color-3, #999); margin: 16px 0 8px; }
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--n-border-color, rgba(255,255,255,.1));
border-radius: 12px;
margin-bottom: 8px;
cursor: pointer;
background: var(--n-card-color);
transition: border-color .15s;
}
.row:hover { border-color: var(--primary-color); }
.row.static, .row.static:hover { cursor: default; border-color: var(--n-border-color, rgba(255,255,255,.1)); }
.row.disabled { opacity: .5; pointer-events: none; }
.row-icon { color: var(--primary-color); }
.row-label { flex: 1; font-weight: 500; }
.row-value { color: var(--n-text-color-3, #999); font-size: 13px; }
.row-chevron { color: var(--n-text-color-3, #bbb); }
.segment {
display: flex;
gap: 4px;
padding: 4px;
border-radius: 10px;
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
margin-bottom: 12px;
}
.seg-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--n-text-color-2);
}
.seg-btn.active { background: var(--n-card-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.data-list { display: flex; flex-direction: column; gap: 8px; }
.data-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--n-border-color, rgba(255,255,255,.1));
border-radius: 12px;
cursor: pointer;
}
.data-card.active { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 6%, transparent); }
.data-title { font-weight: 600; }
.data-desc { font-size: 12px; color: var(--n-text-color-3, #999); margin-top: 2px; }
.filter-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; }
</style>