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