up, add OLAP columns page
This commit is contained in:
@@ -76,6 +76,23 @@
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/olap-columns"
|
||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||
:class="[
|
||||
route.path === '/olap-columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||
]"
|
||||
:title="sidebarCollapsed ? t('app.olapColumns') : ''"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.olapColumns') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="userStore.role === 'admin'"
|
||||
to="/settings"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"users": "Users",
|
||||
"restaurants": "Restaurants",
|
||||
"settings": "Settings",
|
||||
"olapColumns": "OLAP Fields",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout",
|
||||
"language": "Language",
|
||||
@@ -21,7 +22,9 @@
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"reset": "Reset",
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"all": "all",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"common": {
|
||||
"id": "ID",
|
||||
@@ -193,5 +196,43 @@
|
||||
"minLength": "Must be at least {min} characters",
|
||||
"email": "Please enter a valid email address",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
},
|
||||
"olap": {
|
||||
"columnsTitle": "OLAP Reports Structure",
|
||||
"initialize": "Initialize",
|
||||
"filterFieldKey": "Field key",
|
||||
"filterFieldKeyPlaceholder": "search by key...",
|
||||
"filterReportType": "Report type",
|
||||
"filterTag": "Tag",
|
||||
"fieldKey": "Field (key)",
|
||||
"reportTypes": "Report types",
|
||||
"type": "Type",
|
||||
"tags": "Tags",
|
||||
"aggregation": "Aggregation",
|
||||
"grouping": "Grouping",
|
||||
"filtering": "Filtering",
|
||||
"noColumnsFound": "No fields match the filters",
|
||||
"selectRestaurant": "Select a restaurant to load the structure",
|
||||
"loadError": "Error loading report structure",
|
||||
"initSuccess": "Structure initialized successfully",
|
||||
"initError": "Initialization error: {error}",
|
||||
"selectRestaurantFirst": "Please select a restaurant",
|
||||
"refreshStructure": "Refresh structure",
|
||||
"refreshWarningTitle": "Full structure replacement",
|
||||
"refreshWarningMessage": "You selected restaurant «{restaurant}». All existing OLAP fields data will be permanently deleted and replaced with data from this restaurant.",
|
||||
"refreshWarningConfirm": "This action is irreversible. Continue?",
|
||||
"searchRestaurant": "Search restaurant...",
|
||||
"noRestaurantsFound": "No restaurants found",
|
||||
"initializingData": "Initializing OLAP fields structure",
|
||||
"refreshingData": "Refreshing OLAP fields structure",
|
||||
"waitMessage": "Please wait. This operation may take a while...",
|
||||
"editField": "Edit Field",
|
||||
"displayType": "Display Type",
|
||||
"updateSuccess": "Field updated successfully",
|
||||
"updateError": "Error updating field",
|
||||
"deleteSuccess": "Field deleted successfully",
|
||||
"deleteError": "Error deleting field",
|
||||
"deleteField": "Delete Field",
|
||||
"deleteFieldConfirm": "Are you sure you want to delete this field? This action cannot be undone."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
"app": {
|
||||
"title": "Панель администратора",
|
||||
"dashboard": "Панель управления",
|
||||
"database": "База Данных",
|
||||
"database": "База данных",
|
||||
"users": "Пользователи",
|
||||
"restaurants": "Рестораны",
|
||||
"settings": "Настройки",
|
||||
"olapColumns": "OLAP поля",
|
||||
"profile": "Профиль",
|
||||
"logout": "Выйти",
|
||||
"language": "Язык",
|
||||
@@ -21,7 +22,9 @@
|
||||
"edit": "Редактировать",
|
||||
"add": "Добавить",
|
||||
"reset": "Сбросить",
|
||||
"loading": "Загрузка..."
|
||||
"loading": "Загрузка...",
|
||||
"all": "все",
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"common": {
|
||||
"id": "ID",
|
||||
@@ -193,5 +196,43 @@
|
||||
"minLength": "Должно быть не менее {min} символов",
|
||||
"email": "Введите корректный email адрес",
|
||||
"passwordMismatch": "Пароли не совпадают"
|
||||
},
|
||||
"olap": {
|
||||
"columnsTitle": "Структура OLAP-отчётов",
|
||||
"initialize": "Инициализировать",
|
||||
"filterFieldKey": "Ключ поля",
|
||||
"filterFieldKeyPlaceholder": "поиск по ключу...",
|
||||
"filterReportType": "Тип отчёта",
|
||||
"filterTag": "Тег",
|
||||
"fieldKey": "Поле (ключ)",
|
||||
"reportTypes": "Типы отчётов",
|
||||
"type": "Тип",
|
||||
"tags": "Теги",
|
||||
"aggregation": "Агрегация",
|
||||
"grouping": "Группировка",
|
||||
"filtering": "Фильтрация",
|
||||
"noColumnsFound": "Нет полей, соответствующих фильтрам",
|
||||
"selectRestaurant": "Выберите ресторан для загрузки структуры",
|
||||
"loadError": "Ошибка загрузки структуры отчётов",
|
||||
"initSuccess": "Структура успешно инициализирована",
|
||||
"initError": "Ошибка инициализации: {error}",
|
||||
"selectRestaurantFirst": "Пожалуйста, выберите ресторан",
|
||||
"refreshStructure": "Обновить структуру",
|
||||
"refreshWarningTitle": "Полная замена структуры",
|
||||
"refreshWarningMessage": "Вы выбрали ресторан «{restaurant}». Все текущие данные о полях OLAP-отчётов будут полностью удалены и заменены данными из этого ресторана.",
|
||||
"refreshWarningConfirm": "Это действие необратимо. Продолжить?",
|
||||
"searchRestaurant": "Поиск ресторана...",
|
||||
"noRestaurantsFound": "Рестораны не найдены",
|
||||
"initializingData": "Инициализация структуры OLAP-полей",
|
||||
"refreshingData": "Обновление структуры OLAP-полей",
|
||||
"waitMessage": "Пожалуйста, подождите. Операция может занять некоторое время...",
|
||||
"editField": "Редактирование поля",
|
||||
"displayType": "Тип отображения",
|
||||
"updateSuccess": "Поле успешно обновлено",
|
||||
"updateError": "Ошибка при обновлении поля",
|
||||
"deleteSuccess": "Поле успешно удалено",
|
||||
"deleteError": "Ошибка при удалении поля",
|
||||
"deleteField": "Удаление поля",
|
||||
"deleteFieldConfirm": "Вы уверены, что хотите удалить это поле? Это действие необратимо."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,67 @@ import Register from '@/views/auth/Register.vue'
|
||||
import Dashboard from '@/views/Dashboard.vue'
|
||||
import Users from '@/views/Users.vue'
|
||||
import Restaurants from '@/views/Restaurants.vue'
|
||||
import OlapColumnsView from '@/views/OlapColumnsView.vue'
|
||||
import AdminSettings from '@/views/AdminSettings.vue'
|
||||
import Profile from '@/views/Profile.vue'
|
||||
import NotFound from '@/views/NotFound.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
|
||||
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
|
||||
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: 'Dashboard' } },
|
||||
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' } },
|
||||
{ path: '/restaurants', component: Restaurants, meta: { requiresAuth: true, title: 'Restaurants' } },
|
||||
{ path: '/settings', component: AdminSettings, meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' } },
|
||||
{ path: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } },
|
||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } }
|
||||
{
|
||||
path: '/login',
|
||||
component: Login,
|
||||
meta: { title: 'Login', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
component: Register,
|
||||
meta: { title: 'Register', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
component: Setup,
|
||||
meta: { title: 'Setup', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: Dashboard,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: Users,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
|
||||
},
|
||||
{
|
||||
path: '/restaurants',
|
||||
component: Restaurants,
|
||||
meta: { requiresAuth: true, title: 'Restaurants' }
|
||||
},
|
||||
{
|
||||
path: '/olap-columns',
|
||||
component: OlapColumnsView,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'OlapColumns' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: AdminSettings,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true, title: 'Profile' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFound,
|
||||
meta: { title: 'Page Not Found', requiresAuth: false }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
641
frontend/src/views/OlapColumnsView.vue
Normal file
641
frontend/src/views/OlapColumnsView.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ t('olap.columnsTitle') }}</h1>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-if="hasData && !loading && !initializing"
|
||||
@click="openRefreshModal"
|
||||
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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('olap.refreshStructure') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!hasData && !loading && !initializing"
|
||||
@click="openInitModal"
|
||||
class="btn-primary 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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('olap.initialize') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<div v-if="hasData" class="card mb-6 p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterFieldKey') }}</label>
|
||||
<input v-model="filters.fieldKey" type="text" class="input-field" :placeholder="t('olap.filterFieldKeyPlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterReportType') }}</label>
|
||||
<select v-model="filters.reportType" class="input-field">
|
||||
<option value="">{{ t('app.all') }}</option>
|
||||
<option v-for="rt in availableReportTypes" :key="rt" :value="rt">{{ rt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterTag') }}</label>
|
||||
<select v-model="filters.tag" class="input-field">
|
||||
<option value="">{{ t('app.all') }}</option>
|
||||
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button @click="resetFilters" class="btn-secondary w-full">{{ t('app.reset') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div v-if="loading" class="card p-8 text-center">
|
||||
<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">{{ t('app.loading') }}</p>
|
||||
</div>
|
||||
<div v-else-if="hasData && filteredColumns.length === 0" class="card p-8 text-center text-gray-500">
|
||||
{{ t('olap.noColumnsFound') }}
|
||||
</div>
|
||||
<div v-else-if="hasData" class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.fieldKey') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.reportTypes') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.type') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.tags') }}</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.aggregation') }}</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.grouping') }}</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.filtering') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="col in filteredColumns" :key="col.fieldKey" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm font-mono text-gray-900">{{ col.fieldKey }}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">{{ col.name }}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="rt in col.reportTypes" :key="rt" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ rt }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ col.typeNormal || col.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="tag in col.tags" :key="tag" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{{ tag }}
|
||||
</span>
|
||||
<span v-if="!col.tags || col.tags.length === 0" class="text-gray-400">—</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span v-if="col.aggregationAllowed" class="text-green-600">✓</span>
|
||||
<span v-else class="text-gray-300">—</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span v-if="col.groupingAllowed" class="text-green-600">✓</span>
|
||||
<span v-else class="text-gray-300">—</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span v-if="col.filteringAllowed" class="text-green-600">✓</span>
|
||||
<span v-else class="text-gray-300">—</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<!-- <button @click="openEditModal(col)" class="text-blue-600 hover:text-blue-800 transition-colors" :title="t('app.edit')">-->
|
||||
<!-- <svg 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />-->
|
||||
<!-- </svg>-->
|
||||
<!-- </button>-->
|
||||
<button @click="openDeleteFieldModal(col.fieldKey)" class="text-red-600 hover:text-red-800 transition-colors" :title="t('app.delete')">
|
||||
<svg 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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка выбора ресторана (для инициализации и обновления) -->
|
||||
<Transition name="fade">
|
||||
<div v-if="initModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeInitModal">
|
||||
<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-lg w-full max-h-[90vh] flex flex-col">
|
||||
<!-- Заголовок -->
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ initModalTitle }}</h3>
|
||||
<button @click="closeInitModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Поле поиска -->
|
||||
<div class="p-4 border-b">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="restaurantSearch"
|
||||
type="text"
|
||||
class="input-field pl-9"
|
||||
:placeholder="t('olap.searchRestaurant')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список ресторанов (скролл) -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
<div
|
||||
v-for="rest in filteredRestaurants"
|
||||
:key="rest.id"
|
||||
@click="selectedRestaurantId = rest.id"
|
||||
class="flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-150 hover:shadow-md"
|
||||
:class="[
|
||||
selectedRestaurantId === rest.id
|
||||
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ rest.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ rest.host }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedRestaurantId === rest.id" class="text-primary-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredRestaurants.length === 0" class="text-center py-8 text-gray-500">
|
||||
{{ t('olap.noRestaurantsFound') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий -->
|
||||
<div class="flex justify-end space-x-3 p-6 border-t bg-gray-50 rounded-b-2xl">
|
||||
<button @click="closeInitModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button
|
||||
@click="onInitConfirm"
|
||||
:disabled="!selectedRestaurantId"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ t('app.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Модалка предупреждения перед обновлением (после выбора ресторана) -->
|
||||
<Transition name="fade">
|
||||
<div v-if="refreshWarningModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="refreshWarningModal.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">{{ t('olap.refreshWarningTitle') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{{ t('olap.refreshWarningMessage', { restaurant: pendingRestaurantName }) }}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-red-600 mb-6">{{ t('olap.refreshWarningConfirm') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="refreshWarningModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="executeInitialize" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
||||
{{ t('app.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Модалка редактирования поля -->
|
||||
<Transition name="fade">
|
||||
<div v-if="editModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeEditModal">
|
||||
<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="flex justify-between items-center p-6 border-b">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ t('olap.editField') }}</h2>
|
||||
<button @click="closeEditModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="updateField" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }}</label>
|
||||
<input v-model="editForm.name" type="text" class="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.displayType') }}</label>
|
||||
<select v-model="editForm.typeNormal" class="input-field">
|
||||
<option value="string">string</option>
|
||||
<option value="integer">integer</option>
|
||||
<option value="decimal">decimal</option>
|
||||
<option value="datetime">datetime</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="editForm.aggregationAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('olap.aggregation') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="editForm.groupingAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('olap.grouping') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" v-model="editForm.filteringAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||
<label class="text-sm font-medium text-gray-700">{{ t('olap.filtering') }}</label>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="closeEditModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Модалка подтверждения удаления поля -->
|
||||
<Transition name="fade">
|
||||
<div v-if="deleteFieldConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteFieldConfirm.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">{{ t('olap.deleteField') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ t('olap.deleteFieldConfirm') }}</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="deleteFieldConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||
<button @click="confirmDeleteField" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">{{ t('app.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="initializing" class="fixed inset-0 z-60 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center gap-4 max-w-sm w-full mx-4">
|
||||
<svg class="animate-spin h-12 w-12 text-primary-600" 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="text-gray-700 font-medium">
|
||||
{{ initializingText }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{{ t('olap.waitMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNotification } from '@/composables/useNotification';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
interface Column {
|
||||
fieldKey: string;
|
||||
fieldKeyNormal: string;
|
||||
reportTypes: string[];
|
||||
name: string;
|
||||
type: string;
|
||||
typeNormal: string;
|
||||
aggregationAllowed: boolean;
|
||||
groupingAllowed: boolean;
|
||||
filteringAllowed: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Restaurant {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
const columns = ref<Column[]>([]);
|
||||
const loading = ref(false);
|
||||
const initializing = ref(false);
|
||||
const initModalOpen = ref(false);
|
||||
const initModalTitle = ref('');
|
||||
const restaurants = ref<Restaurant[]>([]);
|
||||
const selectedRestaurantId = ref<number | null>(null);
|
||||
|
||||
const pendingRestaurantId = ref<number | null>(null);
|
||||
const pendingRestaurantName = ref('');
|
||||
|
||||
const refreshWarningModal = ref({ show: false });
|
||||
|
||||
const initializingText = ref('');
|
||||
const filters = ref({
|
||||
fieldKey: '',
|
||||
reportType: '',
|
||||
tag: ''
|
||||
});
|
||||
|
||||
const editModalOpen = ref(false);
|
||||
const editForm = ref({
|
||||
fieldKey: '',
|
||||
name: '',
|
||||
typeNormal: '',
|
||||
aggregationAllowed: false,
|
||||
groupingAllowed: false,
|
||||
filteringAllowed: false
|
||||
});
|
||||
|
||||
const deleteFieldConfirm = ref({ show: false, fieldKey: '' });
|
||||
|
||||
const hasData = computed(() => columns.value.length > 0);
|
||||
|
||||
const availableReportTypes = computed(() => {
|
||||
const types = new Set<string>();
|
||||
for (const col of columns.value) {
|
||||
for (const rt of col.reportTypes) {
|
||||
types.add(rt);
|
||||
}
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
});
|
||||
|
||||
const availableTags = computed(() => {
|
||||
const tags = new Set<string>();
|
||||
for (const col of columns.value) {
|
||||
for (const tag of col.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
});
|
||||
|
||||
const filteredColumns = computed(() => {
|
||||
let result = columns.value;
|
||||
if (filters.value.fieldKey) {
|
||||
const lowerKey = filters.value.fieldKey.toLowerCase();
|
||||
result = result.filter(col => col.fieldKey.toLowerCase().includes(lowerKey));
|
||||
}
|
||||
if (filters.value.reportType) {
|
||||
result = result.filter(col => col.reportTypes.includes(filters.value.reportType));
|
||||
}
|
||||
if (filters.value.tag) {
|
||||
result = result.filter(col => col.tags.includes(filters.value.tag));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
async function loadColumns() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/reports/olap/columns');
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 || res.status === 204) {
|
||||
columns.value = [];
|
||||
} else {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
columns.value = data.columns || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showNotification('olap.loadError', 'error');
|
||||
columns.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRestaurants() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/restaurants');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
restaurants.value = data;
|
||||
} else {
|
||||
showNotification('restaurants.loadError', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('restaurants.loadError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const restaurantSearch = ref('');
|
||||
|
||||
const filteredRestaurants = computed(() => {
|
||||
if (!restaurantSearch.value) return restaurants.value;
|
||||
const lower = restaurantSearch.value.toLowerCase();
|
||||
return restaurants.value.filter(r =>
|
||||
r.name.toLowerCase().includes(lower) ||
|
||||
r.host.toLowerCase().includes(lower)
|
||||
);
|
||||
});
|
||||
|
||||
function openInitModal() {
|
||||
initModalTitle.value = t('olap.selectRestaurant');
|
||||
loadRestaurants().then(() => { initModalOpen.value = true; });
|
||||
}
|
||||
|
||||
function openRefreshModal() {
|
||||
initModalTitle.value = t('olap.refreshStructure');
|
||||
loadRestaurants().then(() => { initModalOpen.value = true; });
|
||||
}
|
||||
|
||||
function closeInitModal() {
|
||||
initModalOpen.value = false;
|
||||
selectedRestaurantId.value = null;
|
||||
}
|
||||
|
||||
function onInitConfirm() {
|
||||
if (!selectedRestaurantId.value) return;
|
||||
const selectedRest = restaurants.value.find(r => r.id === selectedRestaurantId.value);
|
||||
pendingRestaurantName.value = selectedRest ? selectedRest.name : '';
|
||||
pendingRestaurantId.value = selectedRestaurantId.value;
|
||||
|
||||
if (hasData.value) {
|
||||
closeInitModal();
|
||||
refreshWarningModal.value.show = true;
|
||||
} else {
|
||||
executeInitialize();
|
||||
}
|
||||
}
|
||||
|
||||
async function executeInitialize() {
|
||||
const id = pendingRestaurantId.value ?? selectedRestaurantId.value;
|
||||
if (!id) {
|
||||
showNotification('olap.selectRestaurantFirst', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
initModalOpen.value = false;
|
||||
refreshWarningModal.value.show = false;
|
||||
editModalOpen.value = false;
|
||||
deleteFieldConfirm.value.show = false;
|
||||
|
||||
initializingText.value = hasData.value ? t('olap.refreshingData') : t('olap.initializingData');
|
||||
initializing.value = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/reports/olap/initialize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ restaurantId: id })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(errText || `HTTP ${res.status}`);
|
||||
}
|
||||
showNotification('olap.initSuccess', 'success');
|
||||
await loadColumns();
|
||||
} catch (error: any) {
|
||||
showNotification('olap.initError', 'error', { error: error.message });
|
||||
} finally {
|
||||
initializing.value = false;
|
||||
initializingText.value = '';
|
||||
|
||||
pendingRestaurantId.value = null;
|
||||
pendingRestaurantName.value = '';
|
||||
selectedRestaurantId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(col: Column) {
|
||||
editForm.value = {
|
||||
fieldKey: col.fieldKey,
|
||||
name: col.name,
|
||||
typeNormal: col.typeNormal,
|
||||
aggregationAllowed: col.aggregationAllowed,
|
||||
groupingAllowed: col.groupingAllowed,
|
||||
filteringAllowed: col.filteringAllowed
|
||||
};
|
||||
editModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
editModalOpen.value = false;
|
||||
}
|
||||
|
||||
async function updateField() {
|
||||
try {
|
||||
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(editForm.value.fieldKey)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editForm.value.name,
|
||||
typeNormal: editForm.value.typeNormal,
|
||||
aggregationAllowed: editForm.value.aggregationAllowed,
|
||||
groupingAllowed: editForm.value.groupingAllowed,
|
||||
filteringAllowed: editForm.value.filteringAllowed
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('olap.updateSuccess', 'success');
|
||||
closeEditModal();
|
||||
await loadColumns();
|
||||
} catch (error) {
|
||||
showNotification('olap.updateError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteFieldModal(fieldKey: string) {
|
||||
deleteFieldConfirm.value = { show: true, fieldKey };
|
||||
}
|
||||
|
||||
async function confirmDeleteField() {
|
||||
const fieldKey = deleteFieldConfirm.value.fieldKey;
|
||||
if (!fieldKey) return;
|
||||
try {
|
||||
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(fieldKey)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
showNotification('olap.deleteSuccess', 'success');
|
||||
deleteFieldConfirm.value.show = false;
|
||||
await loadColumns();
|
||||
} catch (error) {
|
||||
showNotification('olap.deleteError', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { fieldKey: '', reportType: '', tag: '' };
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadColumns();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.z-60 {
|
||||
z-index: 60;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user