Files
onboard/resources/js/Pages/Analytics/Components/SettingsPanel.vue
2026-06-22 17:02:36 +09:00

313 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, 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>