Files
iiko-connector/frontend/src/views/OLAPConstructor.vue
2026-05-07 00:01:28 +03:00

908 lines
40 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.
<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>