R.I.P me........

This commit is contained in:
2026-05-06 21:15:12 +03:00
parent a406af54bd
commit 0e6103f138
8 changed files with 1193 additions and 33 deletions

View File

@@ -0,0 +1,938 @@
<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-col lg:flex-row gap-6">
<!-- Левая панель - поля (с прокруткой и сворачиванием) -->
<div class="lg:w-72 card p-5 flex flex-col max-h-[calc(100vh-12rem)]">
<h3 class="text-xl font-bold mb-4">Поля</h3>
<div class="mb-3">
<input type="text" v-model="searchQuery" placeholder="Поиск по названию или тегам" class="input-field">
<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">
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-2 text-gray-500">Загрузка полей...</p>
</div>
<div v-else-if="error" class="bg-red-50 text-red-700 p-3 rounded-xl text-sm border border-red-200">
{{ error }}
</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-4 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>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">{{ filteredNumberFields.length }}</span>
<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>
<div v-show="!collapsed.number" class="mt-2">
<div v-for="field in filteredNumberFields" :key="field.id"
class="bg-purple-600 text-white p-3 rounded-xl mb-2 cursor-grab active:cursor-grabbing"
draggable="true"
@dragstart="dragStart($event, field)"
@dragend="dragEnd">
{{ field.name }}
<span class="text-[9px] bg-white/20 rounded-full px-1 ml-1">{{ field.tags?.slice(0,2).join(', ') }}</span>
</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>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">{{ filteredCategoryFields.length }}</span>
<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>
<div v-show="!collapsed.category" class="mt-2">
<div v-for="field in filteredCategoryFields" :key="field.id"
class="bg-pink-500 text-white p-3 rounded-xl mb-2 cursor-grab active:cursor-grabbing"
draggable="true"
@dragstart="dragStart($event, field)"
@dragend="dragEnd">
{{ field.name }}
<span class="text-[9px] bg-white/20 rounded-full px-1 ml-1">{{ field.tags?.slice(0,2).join(', ') }}</span>
</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>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">{{ filteredFilterFields.length }}</span>
<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>
<div v-show="!collapsed.filter" class="mt-2">
<div v-for="field in filteredFilterFields" :key="field.id"
class="bg-cyan-600 text-white p-3 rounded-xl mb-2 cursor-grab active:cursor-grabbing"
draggable="true"
@dragstart="dragStart($event, field)"
@dragend="dragEnd">
{{ field.name }}
<span class="text-[9px] bg-white/20 rounded-full px-1 ml-1">{{ field.tags?.slice(0,2).join(', ') }}</span>
</div>
</div>
</div>
</div>
<div class="mt-5 p-3 bg-gray-50 rounded-xl border border-gray-200">
<div class="flex justify-between"><span>ROW:</span><b>{{ rowFields.length }}</b></div>
<div class="flex justify-between mt-1"><span>COLUMN:</span><b>{{ columnFields.length }}</b></div>
<div class="flex justify-between mt-1"><span>VALUES:</span><b>{{ valueFields.length }}</b></div>
<button @click="openResetModal" class="w-full mt-3 bg-gray-700 text-white py-2 rounded-lg hover:bg-gray-800 transition-colors">Сбросить всё</button>
</div>
</div>
<!-- Правая часть -->
<div class="flex-1">
<!-- Верхняя панель настроек -->
<div class="card p-4 mb-4 flex flex-wrap items-center gap-4 justify-between">
<div class="flex items-center gap-3">
<span class="font-bold text-gray-700">Тип отчета:</span>
<select v-model="reportType" class="input-field w-auto">
<option value="SALES">SALES</option>
<option value="DELIVERIES">DELIVERIES</option>
<option value="TRANSACTIONS">TRANSACTIONS</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<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-2 cursor-pointer">
<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-4 mb-4">
<label class="font-medium text-gray-700">Имя таблицы ClickHouse</label>
<input type="text" v-model="tableName" @input="validateTableName"
class="input-field mt-1"
: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-sm mt-1">Должно начинаться с буквы</div>
</div>
<!-- Настройки даты -->
<div class="card p-4 mb-4 grid grid-cols-2 gap-4">
<div>
<label class="font-medium text-gray-700">Дата до (конец дня)</label>
<input type="date" v-model="dateTo" class="input-field">
</div>
<div>
<label class="font-medium text-gray-700">Дней назад (1)</label>
<input type="number" v-model.number="daysBack" min="1" step="1" class="input-field">
</div>
</div>
<!-- Вкладки -->
<div class="card overflow-hidden">
<div class="flex border-b border-gray-200">
<button @click="activeTab='table'"
class="px-4 py-2 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-4 py-2 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-4">
<div v-if="activeTab==='table'">
<!-- Фильтры -->
<div class="mb-4"
@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 mb-2">Пользовательские фильтры</h3>
<div class="flex flex-wrap gap-3">
<div v-for="(f, idx) in filterFields" :key="f.id"
class="inline-flex items-center gap-2 bg-cyan-100 text-cyan-800 px-3 py-1 rounded-full text-sm">
{{ 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">
<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-28" @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-20">
<input v-model="f.enumValue" placeholder="значение" class="text-xs border rounded px-1 w-24">
</template>
<template v-else-if="f.filterType === 'StringValue'">
<input v-model="f.value" placeholder="значение" class="text-xs border rounded px-1 w-24">
</template>
</div>
<button @click="removeFilter(idx)" class="text-cyan-600 hover:text-cyan-800 ml-1"></button>
</div>
<div v-if="!filterFields.length" class="text-gray-400 text-sm">Перетащите поле фильтра</div>
</div>
</div>
<!-- Таблица-сетка -->
<div class="overflow-x-auto border rounded-xl">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="bg-gray-100 p-2 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-sm 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-2 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-2 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-sm 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-2 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-2 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-2 text-left text-xs font-medium text-gray-500">{{ cf.name }}</th>
<th class="bg-gray-100 p-2 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-sm 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-2 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-1" @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-2 text-sm text-gray-400 border-r"></td>
<td v-for="cf in columnFields" :key="'cv'+cf.id" class="p-2 text-sm text-gray-400"></td>
<td v-for="vf in valueFields" :key="'vv'+vf.id" class="p-2 text-sm font-bold text-purple-700"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="activeTab==='sql'">
<div class="flex justify-between items-center mb-3">
<h3 class="font-bold text-gray-800">Скрипт для ClickHouse</h3>
<button @click="copySQL" class="btn-secondary text-sm">Копировать SQL</button>
</div>
<div class="bg-gray-900 text-gray-200 p-4 rounded-xl overflow-x-auto font-mono text-sm 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(true)
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
// Функции работы с полями
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>