908 lines
40 KiB
Vue
908 lines
40 KiB
Vue
<template>
|
||
<AppLayout>
|
||
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
|
||
<h1 class="text-2xl font-bold text-gray-900">OLAP Конструктор</h1>
|
||
<div class="flex gap-3">
|
||
<button @click="saveConfigAsJson" class="btn-secondary flex items-center gap-2">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||
</svg>
|
||
Сохранить JSON
|
||
</button>
|
||
<label class="btn-secondary flex items-center gap-2 cursor-pointer">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
Загрузить JSON
|
||
<input type="file" accept=".json" @change="loadConfigFromJson" class="hidden">
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-row gap-6">
|
||
<!-- Левая панель - поля (sticky, на всю высоту) -->
|
||
<div class="w-72 shrink-0 card p-4 flex flex-col lg:sticky lg:top-4 h-[calc(100vh-12rem)]">
|
||
<h3 class="text-lg font-bold mb-3">Поля</h3>
|
||
<div class="mb-2">
|
||
<input type="text" v-model="searchQuery" placeholder="Поиск по названию или тегам" class="input-field py-1.5 text-sm">
|
||
<div class="text-xs text-gray-500 mt-1" v-if="searchQuery">Найдено: {{ filteredAvailableFields.length }} / {{ availableFields.length }}</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="text-center py-8">...</div>
|
||
<div v-else-if="error" class="bg-red-50 text-red-700 p-3 rounded-xl text-sm">...</div>
|
||
<div v-else-if="availableFields.length === 0" class="text-center py-8 text-gray-500">...</div>
|
||
<div v-else class="flex-1 overflow-y-auto space-y-3 pr-1">
|
||
<!-- Числовые -->
|
||
<div>
|
||
<div class="flex justify-between items-center cursor-pointer select-none" @click="toggleSection('number')">
|
||
<h4 class="text-gray-600 text-sm font-bold">Числовые → VALUES</h4>
|
||
<button class="text-gray-500 hover:text-gray-700">
|
||
<svg v-if="collapsed.number" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div v-show="!collapsed.number" class="mt-2 space-y-1.5">
|
||
<div v-for="field in filteredNumberFields" :key="field.id"
|
||
class="bg-purple-600 text-white p-2 rounded-lg cursor-grab active:cursor-grabbing text-sm"
|
||
draggable="true" @dragstart="dragStart($event, field)" @dragend="dragEnd">
|
||
{{ field.name }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Категории -->
|
||
<div>
|
||
<div class="flex justify-between items-center cursor-pointer select-none" @click="toggleSection('category')">
|
||
<h4 class="text-gray-600 text-sm font-bold">Категории → ROW / COLUMN</h4>
|
||
<button class="text-gray-500 hover:text-gray-700">
|
||
<svg v-if="collapsed.category" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div v-show="!collapsed.category" class="mt-2 space-y-1.5">
|
||
<div v-for="field in filteredCategoryFields" :key="field.id"
|
||
class="bg-pink-500 text-white p-2 rounded-lg cursor-grab active:cursor-grabbing text-sm"
|
||
draggable="true" @dragstart="dragStart($event, field)" @dragend="dragEnd">
|
||
{{ field.name }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Фильтры -->
|
||
<div>
|
||
<div class="flex justify-between items-center cursor-pointer select-none" @click="toggleSection('filter')">
|
||
<h4 class="text-gray-600 text-sm font-bold">Фильтры</h4>
|
||
<button class="text-gray-500 hover:text-gray-700">
|
||
<svg v-if="collapsed.filter" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div v-show="!collapsed.filter" class="mt-2 space-y-1.5">
|
||
<div v-for="field in filteredFilterFields" :key="field.id"
|
||
class="bg-cyan-600 text-white p-2 rounded-lg cursor-grab active:cursor-grabbing text-sm"
|
||
draggable="true" @dragstart="dragStart($event, field)" @dragend="dragEnd">
|
||
{{ field.name }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<button @click="openResetModal" class="btn-secondary w-full py-1.5 text-sm">Сбросить всё</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Правая часть -->
|
||
<div class="flex-1 overflow-x-auto">
|
||
<!-- Верхняя панель настроек -->
|
||
<div class="card p-3 mb-4 flex flex-wrap items-center gap-3 justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<span class="font-bold text-gray-700 text-sm">Тип отчета:</span>
|
||
<select v-model="reportType" class="input-field w-auto py-1 text-sm">
|
||
<option value="SALES">SALES</option>
|
||
<option value="DELIVERIES">DELIVERIES</option>
|
||
<option value="TRANSACTIONS">TRANSACTIONS</option>
|
||
</select>
|
||
</div>
|
||
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
|
||
<input type="checkbox" v-model="buildSummary" class="w-4 h-4 rounded border-gray-300 text-primary-600">
|
||
<span class="font-bold">buildSummary</span>
|
||
</label>
|
||
<label class="flex items-center gap-1.5 cursor-pointer text-sm">
|
||
<input type="checkbox" v-model="active" class="w-4 h-4 rounded border-gray-300 text-primary-600">
|
||
<span class="font-bold">Активно (UI)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Имя таблицы -->
|
||
<div class="card p-3 mb-4">
|
||
<label class="font-medium text-gray-700 text-sm">Имя таблицы ClickHouse</label>
|
||
<input type="text" v-model="tableName" @input="validateTableName"
|
||
class="input-field mt-1 py-1.5 text-sm"
|
||
:class="{'border-green-500 bg-green-50': tableNameValid && tableName, 'border-red-500 bg-red-50': tableNameTouched && !tableNameValid && tableName}">
|
||
<div v-if="tableNameTouched && tableName && !tableNameValid" class="text-red-500 text-xs mt-1">Должно начинаться с буквы</div>
|
||
</div>
|
||
|
||
<!-- Настройки даты -->
|
||
<div class="card p-3 mb-4 grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="font-medium text-gray-700 text-sm">Дата до (конец дня)</label>
|
||
<input type="date" v-model="dateTo" class="input-field py-1.5 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="font-medium text-gray-700 text-sm">Дней назад (≥1)</label>
|
||
<input type="number" v-model.number="daysBack" min="1" step="1" class="input-field py-1.5 text-sm">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Вкладки -->
|
||
<div class="card overflow-hidden">
|
||
<div class="flex border-b border-gray-200">
|
||
<button @click="activeTab='table'"
|
||
class="px-3 py-1.5 text-sm font-medium transition-colors"
|
||
:class="activeTab==='table' ? 'text-primary-600 border-b-2 border-primary-600' : 'text-gray-500 hover:text-gray-700'">
|
||
Таблица
|
||
</button>
|
||
<button @click="activeTab='sql'"
|
||
class="px-3 py-1.5 text-sm font-medium transition-colors"
|
||
:class="activeTab==='sql' ? 'text-primary-600 border-b-2 border-primary-600' : 'text-gray-500 hover:text-gray-700'">
|
||
SQL скрипт
|
||
</button>
|
||
</div>
|
||
<div class="p-3">
|
||
<div v-if="activeTab==='table'">
|
||
<!-- Фильтры -->
|
||
<div class="mb-3"
|
||
@dragover.prevent="dragOverZone = 'filter'"
|
||
@dragleave="dragOverZone = null"
|
||
@drop="dropOnZone('filter', $event)"
|
||
:class="{'ring-2 ring-primary-400 bg-primary-50 rounded-lg': dragOverZone === 'filter'}">
|
||
<h3 class="font-bold text-gray-800 text-sm mb-1.5">Пользовательские фильтры</h3>
|
||
<div class="flex flex-wrap gap-2">
|
||
<div v-for="(f, idx) in filterFields" :key="f.id"
|
||
class="inline-flex items-center gap-1.5 bg-cyan-100 text-cyan-800 px-2 py-1 rounded-full text-xs">
|
||
{{ f.name }}
|
||
<div class="filter-control inline-flex gap-1 ml-1">
|
||
<select v-model="f.filterType" class="text-xs bg-transparent border rounded px-1 py-0">
|
||
<option value="IncludeValues">IncludeValues</option>
|
||
<option value="ExcludeValues">ExcludeValues</option>
|
||
<option value="EnumValue">EnumValue</option>
|
||
<option value="StringValue">StringValue</option>
|
||
</select>
|
||
<template v-if="f.filterType === 'IncludeValues' || f.filterType === 'ExcludeValues'">
|
||
<input type="text" v-model="f.valuesString" placeholder="знач1,знач2" class="text-xs border rounded px-1 w-24 py-0" @input="parseValues(f)">
|
||
</template>
|
||
<template v-else-if="f.filterType === 'EnumValue'">
|
||
<input v-model="f.enumKey" placeholder="enumKey" class="text-xs border rounded px-1 w-16 py-0">
|
||
<input v-model="f.enumValue" placeholder="значение" class="text-xs border rounded px-1 w-20 py-0">
|
||
</template>
|
||
<template v-else-if="f.filterType === 'StringValue'">
|
||
<input v-model="f.value" placeholder="значение" class="text-xs border rounded px-1 w-20 py-0">
|
||
</template>
|
||
</div>
|
||
<button @click="removeFilter(idx)" class="text-cyan-600 hover:text-cyan-800 ml-0.5">✕</button>
|
||
</div>
|
||
<div v-if="!filterFields.length" class="text-gray-400 text-xs">Перетащите поле фильтра</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Таблица-сетка -->
|
||
<div class="overflow-x-auto border rounded-lg">
|
||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||
<thead>
|
||
<tr>
|
||
<th class="bg-gray-100 p-1.5 text-center border-r border-gray-200"
|
||
:colspan="rowFields.length || 1"
|
||
@dragover.prevent="dragOverZone = 'row'"
|
||
@dragleave="dragOverZone = null"
|
||
@drop="dropOnZone('row', $event)"
|
||
:class="{'bg-primary-50': dragOverZone === 'row'}">
|
||
<div class="text-xs font-bold text-gray-600">ROW</div>
|
||
<div class="flex flex-wrap justify-center gap-1 mt-1">
|
||
<div v-for="(f, idx) in rowFields" :key="f.id"
|
||
class="inline-flex items-center gap-1 bg-pink-100 text-pink-800 px-1.5 py-0.5 rounded-full text-xs cursor-move"
|
||
draggable="true"
|
||
@dragstart="dragStartReorder($event, f, 'row', idx)"
|
||
@dragend="dragEndReorder"
|
||
@dragover.prevent="dragOverReorder($event, 'row', idx)"
|
||
@dragleave="dragLeaveReorder"
|
||
@drop="dropReorder($event, 'row', idx)">
|
||
{{ f.name }}
|
||
<button @click="removeRow(idx)" class="text-pink-600 hover:text-pink-800">✕</button>
|
||
</div>
|
||
<span v-if="!rowFields.length" class="text-gray-400 text-xs">Перетащите категорию</span>
|
||
</div>
|
||
</th>
|
||
<th class="bg-gray-100 p-1.5 text-center"
|
||
:colspan="columnFields.length + (valueFields.length || 1)"
|
||
@dragover.prevent="dragOverZone = 'column'"
|
||
@dragleave="dragOverZone = null"
|
||
@drop="dropOnZone('column', $event)"
|
||
:class="{'bg-primary-50': dragOverZone === 'column'}">
|
||
<div class="text-xs font-bold text-gray-600">COLUMN</div>
|
||
<div class="flex flex-wrap justify-center gap-1 mt-1">
|
||
<div v-for="(f, idx) in columnFields" :key="f.id"
|
||
class="inline-flex items-center gap-1 bg-blue-100 text-blue-800 px-1.5 py-0.5 rounded-full text-xs cursor-move"
|
||
draggable="true"
|
||
@dragstart="dragStartReorder($event, f, 'column', idx)"
|
||
@dragend="dragEndReorder"
|
||
@dragover.prevent="dragOverReorder($event, 'column', idx)"
|
||
@dragleave="dragLeaveReorder"
|
||
@drop="dropReorder($event, 'column', idx)">
|
||
{{ f.name }}
|
||
<button @click="removeColumn(idx)" class="text-blue-600 hover:text-blue-800">✕</button>
|
||
</div>
|
||
<span v-if="!columnFields.length" class="text-gray-400 text-xs">Перетащите категорию</span>
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
<tr>
|
||
<th v-for="rf in rowFields" :key="'rh'+rf.id" class="bg-gray-50 p-1.5 text-left text-xs font-medium text-gray-500 border-r">{{ rf.name }}</th>
|
||
<th v-for="cf in columnFields" :key="'ch'+cf.id" class="bg-gray-50 p-1.5 text-left text-xs font-medium text-gray-500">{{ cf.name }}</th>
|
||
<th class="bg-gray-100 p-1.5 text-center"
|
||
:colspan="valueFields.length || 1"
|
||
@dragover.prevent="dragOverZone = 'value'"
|
||
@dragleave="dragOverZone = null"
|
||
@drop="dropOnZone('value', $event)"
|
||
:class="{'bg-primary-50': dragOverZone === 'value'}">
|
||
<div class="text-xs font-bold text-gray-600">VALUES</div>
|
||
<div class="flex flex-wrap justify-center gap-1 mt-1">
|
||
<div v-for="(f, idx) in valueFields" :key="f.id"
|
||
class="inline-flex items-center gap-1 bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded-full text-xs cursor-move"
|
||
draggable="true"
|
||
@dragstart="dragStartReorder($event, f, 'value', idx)"
|
||
@dragend="dragEndReorder"
|
||
@dragover.prevent="dragOverReorder($event, 'value', idx)"
|
||
@dragleave="dragLeaveReorder"
|
||
@drop="dropReorder($event, 'value', idx)">
|
||
{{ f.name }}
|
||
<select v-model="f.aggregation" class="text-xs bg-transparent border rounded px-0.5 py-0" @click.stop>
|
||
<option value="sum">SUM</option>
|
||
<option value="avg">AVG</option>
|
||
<option value="count">COUNT</option>
|
||
</select>
|
||
<button @click="removeValue(idx)" class="text-amber-600 hover:text-amber-800">✕</button>
|
||
</div>
|
||
<span v-if="!valueFields.length" class="text-gray-400 text-xs">Перетащите число</span>
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="i in 3" :key="i" class="border-t">
|
||
<td v-for="rf in rowFields" :key="'rv'+rf.id" class="p-1.5 text-xs text-gray-400 border-r">—</td>
|
||
<td v-for="cf in columnFields" :key="'cv'+cf.id" class="p-1.5 text-xs text-gray-400">—</td>
|
||
<td v-for="vf in valueFields" :key="'vv'+vf.id" class="p-1.5 text-xs font-bold text-purple-700">—</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab==='sql'">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<h3 class="font-bold text-gray-800 text-sm">Скрипт для ClickHouse</h3>
|
||
<button @click="copySQL" class="btn-secondary text-xs py-1 px-2">Копировать SQL</button>
|
||
</div>
|
||
<div class="bg-gray-900 text-gray-200 p-3 rounded-lg overflow-x-auto font-mono text-xs whitespace-pre-wrap">
|
||
<pre>{{ sqlScript }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модалка подтверждения сброса (без изменений) -->
|
||
<Transition name="fade">
|
||
<div v-if="resetModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="resetModal.show = false">
|
||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||
<div class="flex items-center justify-center min-h-screen p-4">
|
||
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||
<div class="p-6 text-center">
|
||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
</div>
|
||
<h3 class="text-lg font-medium text-gray-900 mb-2">Сброс всех настроек</h3>
|
||
<p class="text-sm text-gray-500 mb-6">Вы уверены? Все выбранные поля, фильтры настройки будут удалены.</p>
|
||
<div class="flex justify-center space-x-3">
|
||
<button @click="resetModal.show = false" class="btn-secondary">Отмена</button>
|
||
<button @click="confirmReset" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">Сбросить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</AppLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted } from 'vue'
|
||
import AppLayout from '@/components/Layout/AppLayout.vue'
|
||
import { useNotification } from '@/composables/useNotification'
|
||
|
||
const { showNotification } = useNotification()
|
||
|
||
// Типы (без изменений)
|
||
interface ApiColumn {
|
||
fieldKey: string
|
||
fieldKeyNormal: string
|
||
name: string
|
||
aggregationAllowed: boolean
|
||
groupingAllowed: boolean
|
||
filteringAllowed: boolean
|
||
tags: string[]
|
||
reportTypes: string[]
|
||
}
|
||
|
||
interface BaseField {
|
||
id: string
|
||
fieldKey: string
|
||
fieldKeyNormal: string
|
||
name: string
|
||
tags: string[]
|
||
}
|
||
|
||
interface NumberField extends BaseField {
|
||
role: 'number'
|
||
aggregation: 'sum' | 'avg' | 'count'
|
||
}
|
||
|
||
interface CategoryField extends BaseField {
|
||
role: 'category'
|
||
}
|
||
|
||
interface FilterField extends BaseField {
|
||
role: 'filter'
|
||
filterType: 'IncludeValues' | 'ExcludeValues' | 'EnumValue' | 'StringValue'
|
||
valuesString: string
|
||
values: string[]
|
||
enumKey: string
|
||
enumValue: string
|
||
value: string
|
||
}
|
||
|
||
type AvailableField = NumberField | CategoryField | FilterField
|
||
|
||
interface IikoConfigFilter {
|
||
filterType: string
|
||
values?: string[]
|
||
enumKey?: string
|
||
enumValue?: string
|
||
value?: string
|
||
from?: string
|
||
to?: string
|
||
periodType?: string
|
||
}
|
||
|
||
interface IikoConfig {
|
||
reportType: string
|
||
buildSummary: boolean
|
||
groupByRowFields: string[]
|
||
groupByColFields: string[]
|
||
aggregateFields: string[]
|
||
filters: Record<string, IikoConfigFilter>
|
||
}
|
||
|
||
// Refs
|
||
const columnsData = ref<ApiColumn[]>([])
|
||
const loading = ref(true)
|
||
const error = ref<string | null>(null)
|
||
|
||
const rowFields = ref<CategoryField[]>([])
|
||
const columnFields = ref<CategoryField[]>([])
|
||
const valueFields = ref<NumberField[]>([])
|
||
const filterFields = ref<FilterField[]>([])
|
||
const availableFields = ref<AvailableField[]>([])
|
||
|
||
const dateTo = ref('')
|
||
const daysBack = ref(7)
|
||
const active = ref(true)
|
||
const tableName = ref('')
|
||
const tableNameValid = ref(true)
|
||
const tableNameTouched = ref(false)
|
||
const reportType = ref<'SALES' | 'DELIVERIES' | 'TRANSACTIONS'>('SALES')
|
||
const buildSummary = ref(false)
|
||
const searchQuery = ref('')
|
||
const activeTab = ref<'table' | 'sql'>('table')
|
||
|
||
// Состояние сворачивания секций
|
||
const collapsed = ref({
|
||
number: false,
|
||
category: false,
|
||
filter: false
|
||
})
|
||
|
||
// Метод переключения
|
||
const toggleSection = (section: 'number' | 'category' | 'filter') => {
|
||
collapsed.value[section] = !collapsed.value[section]
|
||
}
|
||
|
||
// Модалка сброса
|
||
const resetModal = ref({ show: false })
|
||
|
||
// Drag & drop state (без изменений)
|
||
let draggedItem: AvailableField | null = null
|
||
let draggedFromSidebar = true
|
||
const dragOverZone = ref<string | null>(null)
|
||
let dragReorderItem: CategoryField | NumberField | null = null
|
||
let dragReorderType: string | null = null
|
||
let dragReorderFromIdx: number | null = null
|
||
|
||
// Функции (без изменений, кроме удаления collapsed)
|
||
const buildAvailableFields = (): AvailableField[] => {
|
||
if (!columnsData.value.length) return []
|
||
const selected = reportType.value
|
||
const result: AvailableField[] = []
|
||
for (const col of columnsData.value) {
|
||
const okForReport = !col.reportTypes || col.reportTypes.length === 0 || col.reportTypes.includes(selected)
|
||
if (!okForReport) continue
|
||
|
||
if (col.aggregationAllowed) {
|
||
result.push({
|
||
id: `${col.fieldKey}_number`,
|
||
fieldKey: col.fieldKey,
|
||
fieldKeyNormal: col.fieldKeyNormal,
|
||
name: col.name,
|
||
role: 'number',
|
||
tags: col.tags || [],
|
||
aggregation: 'sum'
|
||
} as NumberField)
|
||
}
|
||
if (col.groupingAllowed) {
|
||
result.push({
|
||
id: `${col.fieldKey}_category`,
|
||
fieldKey: col.fieldKey,
|
||
fieldKeyNormal: col.fieldKeyNormal,
|
||
name: col.name,
|
||
role: 'category',
|
||
tags: col.tags || []
|
||
} as CategoryField)
|
||
}
|
||
if (col.filteringAllowed) {
|
||
result.push({
|
||
id: `${col.fieldKey}_filter`,
|
||
fieldKey: col.fieldKey,
|
||
fieldKeyNormal: col.fieldKeyNormal,
|
||
name: col.name,
|
||
role: 'filter',
|
||
tags: col.tags || []
|
||
} as FilterField)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
const refreshFieldsAndReset = () => {
|
||
availableFields.value = buildAvailableFields()
|
||
rowFields.value = []
|
||
columnFields.value = []
|
||
valueFields.value = []
|
||
filterFields.value = []
|
||
}
|
||
|
||
const fetchColumns = async () => {
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const response = await fetch('/api/reports/olap/columns')
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||
const data = await response.json()
|
||
columnsData.value = data.columns || []
|
||
refreshFieldsAndReset()
|
||
} catch (err: any) {
|
||
console.error(err)
|
||
error.value = `Ошибка загрузки полей: ${err.message}`
|
||
showNotification(error.value, 'error')
|
||
columnsData.value = []
|
||
refreshFieldsAndReset()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
watch(reportType, () => {
|
||
refreshFieldsAndReset()
|
||
})
|
||
|
||
const matchesSearch = (f: AvailableField): boolean => {
|
||
if (!searchQuery.value.trim()) return true
|
||
const terms = searchQuery.value.toLowerCase().split(/\s+/)
|
||
const nameMatch = terms.some(t => f.name.toLowerCase().includes(t))
|
||
const tagsMatch = f.tags ? terms.some(t => f.tags.some(tag => tag.toLowerCase().includes(t))) : false
|
||
return nameMatch || tagsMatch
|
||
}
|
||
|
||
const filteredAvailableFields = computed(() => availableFields.value.filter(matchesSearch))
|
||
const filteredNumberFields = computed(() => availableFields.value.filter((f): f is NumberField => f.role === 'number' && matchesSearch(f)))
|
||
const filteredCategoryFields = computed(() => availableFields.value.filter((f): f is CategoryField => f.role === 'category' && matchesSearch(f)))
|
||
const filteredFilterFields = computed(() => availableFields.value.filter((f): f is FilterField => f.role === 'filter' && matchesSearch(f)))
|
||
|
||
const validateTableName = () => {
|
||
tableNameTouched.value = true
|
||
if (!tableName.value) { tableNameValid.value = true; return }
|
||
tableNameValid.value = /^[A-Za-zА-Яа-я]/.test(tableName.value)
|
||
}
|
||
|
||
const parseValues = (f: FilterField) => {
|
||
f.values = f.valuesString ? f.valuesString.split(',').map(s => s.trim()).filter(s => s) : []
|
||
}
|
||
|
||
const createNewFilter = (src: FilterField): FilterField => ({
|
||
...src,
|
||
id: Date.now() + Math.random().toString(),
|
||
filterType: 'IncludeValues',
|
||
valuesString: '',
|
||
values: [],
|
||
enumKey: '',
|
||
enumValue: '',
|
||
value: ''
|
||
})
|
||
|
||
const dragStart = (e: DragEvent, f: AvailableField) => {
|
||
draggedItem = f
|
||
draggedFromSidebar = true
|
||
e.dataTransfer!.effectAllowed = 'copy'
|
||
const target = e.target as HTMLElement
|
||
target.classList.add('opacity-40')
|
||
}
|
||
|
||
const dragEnd = (e: DragEvent) => {
|
||
const target = e.target as HTMLElement
|
||
target.classList.remove('opacity-40')
|
||
draggedItem = null
|
||
draggedFromSidebar = true
|
||
dragOverZone.value = null
|
||
}
|
||
|
||
const dropOnZone = (zone: string, e: DragEvent) => {
|
||
e.preventDefault()
|
||
dragOverZone.value = null
|
||
if (!draggedItem || !draggedFromSidebar) return
|
||
const item = draggedItem
|
||
if (zone === 'row' && item.role !== 'category') return
|
||
if (zone === 'column' && item.role !== 'category') return
|
||
if (zone === 'value' && item.role !== 'number') return
|
||
if (zone === 'filter' && item.role !== 'filter') return
|
||
|
||
const idx = availableFields.value.findIndex(f => f.id === item.id)
|
||
if (idx === -1) return
|
||
availableFields.value.splice(idx, 1)
|
||
|
||
if (zone === 'filter') {
|
||
const newField = createNewFilter(item as FilterField)
|
||
filterFields.value.push(newField)
|
||
} else if (zone === 'value') {
|
||
const newField: NumberField = { ...(item as NumberField), id: Date.now() + Math.random().toString(), aggregation: 'sum' }
|
||
valueFields.value.push(newField)
|
||
} else {
|
||
const newField = { ...item, id: Date.now() + Math.random().toString() } as CategoryField
|
||
if (zone === 'row') rowFields.value.push(newField)
|
||
else if (zone === 'column') columnFields.value.push(newField)
|
||
}
|
||
draggedItem = null
|
||
}
|
||
|
||
const dragStartReorder = (e: DragEvent, f: CategoryField | NumberField, type: string, idx: number) => {
|
||
e.dataTransfer!.effectAllowed = 'move'
|
||
dragReorderItem = f
|
||
dragReorderType = type
|
||
dragReorderFromIdx = idx
|
||
const target = e.target as HTMLElement
|
||
target.classList.add('opacity-40')
|
||
}
|
||
|
||
const dragEndReorder = (e: DragEvent) => {
|
||
const target = e.target as HTMLElement
|
||
target.classList.remove('opacity-40')
|
||
dragReorderItem = null
|
||
dragReorderType = null
|
||
dragReorderFromIdx = null
|
||
}
|
||
|
||
const dragOverReorder = (e: DragEvent, type: string, idx: number) => {
|
||
e.preventDefault()
|
||
if (dragReorderType !== type) return
|
||
if (dragReorderFromIdx === idx) return
|
||
const target = e.target as HTMLElement
|
||
const chip = target.closest('.cursor-move') as HTMLElement
|
||
if (chip) chip.classList.add('ring-2', 'ring-primary-400')
|
||
}
|
||
|
||
const dragLeaveReorder = (e: DragEvent) => {
|
||
const target = e.target as HTMLElement
|
||
const chip = target.closest('.cursor-move') as HTMLElement
|
||
if (chip) chip.classList.remove('ring-2', 'ring-primary-400')
|
||
}
|
||
|
||
const dropReorder = (e: DragEvent, type: string, toIdx: number) => {
|
||
e.preventDefault()
|
||
const target = e.target as HTMLElement
|
||
const chip = target.closest('.cursor-move') as HTMLElement
|
||
if (chip) chip.classList.remove('ring-2', 'ring-primary-400')
|
||
if (dragReorderType !== type) return
|
||
let arr: (CategoryField | NumberField)[]
|
||
if (type === 'row') arr = rowFields.value
|
||
else if (type === 'column') arr = columnFields.value
|
||
else if (type === 'value') arr = valueFields.value
|
||
else return
|
||
const fromIdx = dragReorderFromIdx!
|
||
const item = arr[fromIdx]
|
||
arr.splice(fromIdx, 1)
|
||
arr.splice(toIdx, 0, item)
|
||
dragReorderItem = null
|
||
dragReorderType = null
|
||
dragReorderFromIdx = null
|
||
}
|
||
|
||
const restoreFieldToAvailable = (field: AvailableField, expectedRole: string) => {
|
||
const exists = availableFields.value.some(f => f.fieldKey === field.fieldKey && f.role === expectedRole)
|
||
if (!exists) {
|
||
const originalCol = columnsData.value.find(c => c.fieldKey === field.fieldKey)
|
||
if (originalCol) {
|
||
if (expectedRole === 'number' && originalCol.aggregationAllowed) {
|
||
availableFields.value.push({
|
||
id: `${field.fieldKey}_number`,
|
||
fieldKey: field.fieldKey,
|
||
fieldKeyNormal: originalCol.fieldKeyNormal,
|
||
name: originalCol.name,
|
||
role: 'number',
|
||
tags: originalCol.tags || [],
|
||
aggregation: 'sum'
|
||
} as NumberField)
|
||
} else if (expectedRole === 'category' && originalCol.groupingAllowed) {
|
||
availableFields.value.push({
|
||
id: `${field.fieldKey}_category`,
|
||
fieldKey: field.fieldKey,
|
||
fieldKeyNormal: originalCol.fieldKeyNormal,
|
||
name: originalCol.name,
|
||
role: 'category',
|
||
tags: originalCol.tags || []
|
||
} as CategoryField)
|
||
} else if (expectedRole === 'filter' && originalCol.filteringAllowed) {
|
||
availableFields.value.push({
|
||
id: `${field.fieldKey}_filter`,
|
||
fieldKey: field.fieldKey,
|
||
fieldKeyNormal: originalCol.fieldKeyNormal,
|
||
name: originalCol.name,
|
||
role: 'filter',
|
||
tags: originalCol.tags || []
|
||
} as FilterField)
|
||
}
|
||
} else {
|
||
availableFields.value.push({ ...field, id: `${field.fieldKey}_${expectedRole}` } as AvailableField)
|
||
}
|
||
}
|
||
}
|
||
|
||
const removeRow = (idx: number) => {
|
||
const f = rowFields.value[idx]
|
||
rowFields.value.splice(idx, 1)
|
||
restoreFieldToAvailable(f, 'category')
|
||
}
|
||
|
||
const removeColumn = (idx: number) => {
|
||
const f = columnFields.value[idx]
|
||
columnFields.value.splice(idx, 1)
|
||
restoreFieldToAvailable(f, 'category')
|
||
}
|
||
|
||
const removeValue = (idx: number) => {
|
||
const f = valueFields.value[idx]
|
||
valueFields.value.splice(idx, 1)
|
||
restoreFieldToAvailable(f, 'number')
|
||
}
|
||
|
||
const removeFilter = (idx: number) => {
|
||
const f = filterFields.value[idx]
|
||
filterFields.value.splice(idx, 1)
|
||
restoreFieldToAvailable(f, 'filter')
|
||
}
|
||
|
||
const openResetModal = () => {
|
||
resetModal.value.show = true
|
||
}
|
||
|
||
const confirmReset = () => {
|
||
resetModal.value.show = false
|
||
refreshFieldsAndReset()
|
||
dateTo.value = ''
|
||
daysBack.value = 7
|
||
active.value = true
|
||
tableName.value = ''
|
||
tableNameValid.value = true
|
||
tableNameTouched.value = false
|
||
buildSummary.value = true
|
||
searchQuery.value = ''
|
||
showNotification('Все настройки сброшены', 'success')
|
||
}
|
||
|
||
const saveConfigAsJson = () => {
|
||
const userFilters: Record<string, IikoConfigFilter> = {}
|
||
filterFields.value.forEach(f => {
|
||
if (f.filterType === 'IncludeValues' || f.filterType === 'ExcludeValues') {
|
||
userFilters[f.fieldKey] = { filterType: f.filterType, values: f.values || [] }
|
||
} else if (f.filterType === 'EnumValue') {
|
||
userFilters[f.fieldKey] = { filterType: 'EnumValue', enumKey: f.enumKey || '', enumValue: f.enumValue || '' }
|
||
} else if (f.filterType === 'StringValue') {
|
||
userFilters[f.fieldKey] = { filterType: 'StringValue', value: f.value || '' }
|
||
}
|
||
})
|
||
|
||
const getDateFilter = (): Record<string, IikoConfigFilter> => {
|
||
let toDate = dateTo.value ? new Date(dateTo.value) : new Date()
|
||
toDate.setHours(23, 59, 59, 999)
|
||
let fromDate = new Date(toDate)
|
||
const days = Math.max(1, daysBack.value || 1)
|
||
fromDate.setDate(toDate.getDate() - days)
|
||
fromDate.setHours(0, 0, 0, 0)
|
||
const formatDateTime = (date: Date) => date.toISOString()
|
||
const filterKey = reportType.value === 'TRANSACTIONS' ? 'DateTime.DateTyped' : 'OpenDate.Typed'
|
||
return { [filterKey]: { filterType: "DateRange", periodType: "CUSTOM", from: formatDateTime(fromDate), to: formatDateTime(toDate) } }
|
||
}
|
||
|
||
const systemFilters: Record<string, IikoConfigFilter> = {
|
||
"DeletedWithWriteoff": { filterType: "ExcludeValues", values: ["DELETED_WITH_WRITEOFF", "DELETED_WITHOUT_WRITEOFF"] },
|
||
"OrderDeleted": { filterType: "IncludeValues", values: ["NOT_DELETED"] }
|
||
}
|
||
const allFilters = { ...userFilters, ...getDateFilter(), ...systemFilters }
|
||
|
||
const config: IikoConfig = {
|
||
reportType: reportType.value,
|
||
buildSummary: buildSummary.value,
|
||
groupByRowFields: rowFields.value.map(f => f.fieldKey),
|
||
groupByColFields: columnFields.value.map(f => f.fieldKey),
|
||
aggregateFields: valueFields.value.map(f => f.fieldKey),
|
||
filters: allFilters
|
||
}
|
||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `iiko_olap_${reportType.value.toLowerCase()}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
showNotification('JSON для iiko API сохранён', 'success')
|
||
}
|
||
|
||
const loadConfigFromJson = (event: Event) => {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
try {
|
||
const config: IikoConfig = JSON.parse(e.target?.result as string)
|
||
if (config.reportType) reportType.value = config.reportType as 'SALES' | 'DELIVERIES' | 'TRANSACTIONS'
|
||
if (typeof config.buildSummary === 'boolean') buildSummary.value = config.buildSummary
|
||
|
||
const filterKey = reportType.value === 'TRANSACTIONS' ? 'DateTime.DateTyped' : 'OpenDate.Typed'
|
||
const dateFilter = config.filters?.[filterKey]
|
||
if (dateFilter && dateFilter.filterType === 'DateRange') {
|
||
if (dateFilter.to) {
|
||
const toD = new Date(dateFilter.to)
|
||
if (!isNaN(toD.getTime())) dateTo.value = toD.toISOString().split('T')[0]
|
||
}
|
||
if (dateFilter.from && dateFilter.to) {
|
||
const fromD = new Date(dateFilter.from), toD = new Date(dateFilter.to)
|
||
const diff = Math.ceil((toD.getTime() - fromD.getTime()) / (1000 * 3600 * 24))
|
||
if (diff > 0) daysBack.value = diff
|
||
}
|
||
}
|
||
|
||
refreshFieldsAndReset()
|
||
|
||
const addFieldByKey = (zoneType: string, fieldKey: string, expectedRole: string, extra: Partial<NumberField> = {}) => {
|
||
const found = availableFields.value.find(f => f.fieldKey === fieldKey && f.role === expectedRole)
|
||
if (found) {
|
||
const idx = availableFields.value.indexOf(found)
|
||
availableFields.value.splice(idx, 1)
|
||
const newField = { ...found, id: Date.now() + Math.random().toString(), ...extra } as AvailableField
|
||
if (zoneType === 'row') rowFields.value.push(newField as CategoryField)
|
||
else if (zoneType === 'column') columnFields.value.push(newField as CategoryField)
|
||
else if (zoneType === 'value') valueFields.value.push(newField as NumberField)
|
||
else if (zoneType === 'filter') filterFields.value.push(newField as FilterField)
|
||
}
|
||
}
|
||
|
||
if (config.groupByRowFields) config.groupByRowFields.forEach(fk => addFieldByKey('row', fk, 'category'))
|
||
if (config.groupByColFields) config.groupByColFields.forEach(fk => addFieldByKey('column', fk, 'category'))
|
||
if (config.aggregateFields) config.aggregateFields.forEach(fk => addFieldByKey('value', fk, 'number', { aggregation: 'sum' }))
|
||
|
||
if (config.filters) {
|
||
Object.entries(config.filters).forEach(([fk, filterDef]) => {
|
||
if (fk === 'OpenDate.Typed' || fk === 'DateTime.DateTyped' || fk === 'DeletedWithWriteoff' || fk === 'OrderDeleted') return
|
||
const availableFilter = availableFields.value.find(f => f.fieldKey === fk && f.role === 'filter') as FilterField | undefined
|
||
if (availableFilter) {
|
||
const idx = availableFields.value.indexOf(availableFilter)
|
||
availableFields.value.splice(idx, 1)
|
||
const newFilter = createNewFilter(availableFilter)
|
||
newFilter.filterType = filterDef.filterType as FilterField['filterType']
|
||
if (filterDef.filterType === 'IncludeValues' || filterDef.filterType === 'ExcludeValues') {
|
||
newFilter.values = filterDef.values || []
|
||
newFilter.valuesString = newFilter.values.join(', ')
|
||
} else if (filterDef.filterType === 'EnumValue') {
|
||
newFilter.enumKey = filterDef.enumKey || ''
|
||
newFilter.enumValue = filterDef.enumValue || ''
|
||
} else if (filterDef.filterType === 'StringValue') {
|
||
newFilter.value = filterDef.value || ''
|
||
}
|
||
filterFields.value.push(newFilter)
|
||
}
|
||
})
|
||
}
|
||
showNotification('Конфигурация iiko загружена', 'success')
|
||
} catch (err: any) {
|
||
showNotification('Ошибка при загрузке JSON: ' + err.message, 'error')
|
||
}
|
||
input.value = ''
|
||
}
|
||
reader.readAsText(file)
|
||
}
|
||
|
||
const sqlScript = computed(() => {
|
||
const dateCol = reportType.value === 'TRANSACTIONS' ? 'DateTime_DateTyped' : 'OpenDate_Typed'
|
||
const columns: { name: string; type: string }[] = [{ name: dateCol, type: 'Date' }]
|
||
rowFields.value.forEach(rf => columns.push({ name: rf.fieldKeyNormal, type: 'String' }))
|
||
columnFields.value.forEach(cf => columns.push({ name: cf.fieldKeyNormal, type: 'String' }))
|
||
valueFields.value.forEach(vf => columns.push({ name: vf.fieldKeyNormal, type: 'Int64' }))
|
||
if (columns.length === 1) columns.push({ name: 'dummy', type: 'String' })
|
||
let table = tableName.value.trim() || 'olap_table'
|
||
table = table.replace(/[^a-zA-Z0-9_]/g, '_')
|
||
if (!table.match(/^[a-zA-Z_]/)) table = '_' + table
|
||
const fullTable = `\`default\`.\`${table}\``
|
||
const colDefs = columns.map(c => ` \`${c.name}\` ${c.type}`).join(',\n')
|
||
const orderBy = `\`${dateCol}\``
|
||
const colNames = columns.map(c => `\`${c.name}\``).join(', ')
|
||
return `CREATE TABLE IF NOT EXISTS ${fullTable} (\n${colDefs}\n) ENGINE = ReplacingMergeTree()\nORDER BY (${orderBy})\nSETTINGS index_granularity = 8192;\n\nINSERT INTO ${fullTable} (${colNames}) VALUES\n`
|
||
})
|
||
|
||
const copySQL = () => {
|
||
navigator.clipboard.writeText(sqlScript.value)
|
||
showNotification('SQL скрипт скопирован', 'success')
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchColumns()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
/* Стили для скроллбара внутри левой панели */
|
||
.overflow-y-auto::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-track {
|
||
background: #f1f1f1;
|
||
border-radius: 4px;
|
||
}
|
||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 4px;
|
||
}
|
||
</style>
|