fix frontend

This commit is contained in:
2026-04-24 01:44:03 +03:00
parent ff46a37956
commit 1c7e05f6a3
10 changed files with 260 additions and 209 deletions

View File

@@ -191,6 +191,18 @@
<slot />
</div>
</main>
<!-- Notification Toast -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</div>
</template>
@@ -200,7 +212,9 @@ import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useI18n } from 'vue-i18n'
import { useNotification } from '../../composables/useNotification'
const { notification } = useNotification()
const settings = useSettingsStore()
const userStore = useUserStore()
const route = useRoute()
@@ -237,6 +251,7 @@ async function toggleLanguage() {
locale.value = newLang
localStorage.setItem('locale', newLang)
} else {
showNotification('profile.updateError', 'error');
// В случае ошибки всё равно меняем локаль, но не сохраняем в БД
locale.value = newLang
localStorage.setItem('locale', newLang)
@@ -248,3 +263,15 @@ async function toggleLanguage() {
}
}
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,43 @@
import { ref, readonly } from 'vue'
import { useI18n } from 'vue-i18n'
type NotificationType = 'success' | 'error'
interface Notification {
show: boolean
type: NotificationType
message: string
}
const notification = ref<Notification>({
show: false,
type: 'success',
message: ''
})
let timeoutId: number | null = null
export function useNotification() {
const { t } = useI18n()
const showNotification = (messageKey: string, type: NotificationType = 'success', params?: Record<string, any>) => {
const message = params ? t(messageKey, params) : t(messageKey)
// Очищаем предыдущий таймер, чтобы уведомление не закрылось раньше времени
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
notification.value = { show: true, type, message }
timeoutId = window.setTimeout(() => {
notification.value.show = false
timeoutId = null
}, 3000)
}
return {
notification: readonly(notification),
showNotification
}
}

View File

@@ -45,7 +45,8 @@
"leavePasswordBlank": "Leave blank to keep current password",
"deleteConfirmation": "Are you sure you want to delete this item? This action cannot be undone.",
"operationSuccess": "Operation completed successfully",
"operationFailed": "Operation failed"
"operationFailed": "Operation failed",
"networkError": "Network error"
},
"dashboard": {
"totalUsers": "Total Users",
@@ -68,7 +69,8 @@
"new": "New",
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{count} days ago"
"daysAgo": "{count} days ago",
"loadError": "Failed to load dashboard data"
},
"users": {
"pageName": "Users Management",
@@ -76,9 +78,21 @@
"edit": "Edit User",
"delete": "Delete User",
"you": "(You)",
"cannotChangeOwnRole": "You cannot change your own role",
"confirmDelete": "Delete User",
"deleteConfirmation": "Are you sure you want to delete this user? This action cannot be undone."
"deleteConfirmation": "Are you sure you want to delete this user? This action cannot be undone.",
"statusUpdated": "User status updated",
"statusUpdateError": "Failed to update user status",
"passwordRequired": "Password is required for new user",
"createSuccess": "User created successfully",
"createError": "Failed to create user",
"updateSuccess": "User updated successfully",
"updateError": "Failed to update user",
"deleteSuccess": "User deleted",
"deleteError": "Failed to delete user",
"cannotChangeOwnRole": "You cannot change your own role",
"noUsers": "No users found",
"loadError": "Failed to load users",
"loadCurrentError": "Failed to load current user info"
},
"restaurants": {
"pageName": "Restaurants",
@@ -92,7 +106,7 @@
"noRestaurants": "No restaurants found. Click \"Add Restaurant\" to create one.",
"deleteConfirmation": "Are you sure you want to delete this restaurant? This action cannot be undone.",
"check": "Check connection",
"checkError": "Error",
"checkError": "Check failed: {error}",
"loadError": "Failed to load restaurants",
"createSuccess": "Restaurant created successfully",
"updateSuccess": "Restaurant updated successfully",
@@ -109,7 +123,10 @@
"saved": "Settings saved successfully",
"saveFailed": "Failed to save settings",
"loadFailed": "Failed to load settings metadata",
"enabled": "Enabled"
"enabled": "Enabled",
"saveSuccess": "Settings saved successfully",
"saveError": "Failed to save settings",
"loadMetaError": "Failed to load settings metadata"
},
"profile": {
"title": "My Profile",
@@ -122,9 +139,9 @@
"save": "Save Changes",
"reset": "Reset",
"role": "Role",
"passwordMismatch": "Passwords do not match",
"passwordsMismatch": "Passwords do not match",
"updateSuccess": "Profile updated successfully",
"updateFailed": "Failed to update profile"
"updateError": "Failed to update profile"
},
"login": {
"title": "Welcome Back",

View File

@@ -45,7 +45,8 @@
"leavePasswordBlank": "Оставьте пустым, чтобы оставить текущий пароль",
"deleteConfirmation": "Вы уверены, что хотите удалить этот элемент? Это действие необратимо.",
"operationSuccess": "Операция выполнена успешно",
"operationFailed": "Операция не удалась"
"operationFailed": "Операция не удалась",
"networkError": "Ошибка сети"
},
"dashboard": {
"totalUsers": "Всего пользователей",
@@ -68,7 +69,8 @@
"new": "Новый",
"today": "Сегодня",
"yesterday": "Вчера",
"daysAgo": "дн. назад"
"daysAgo": "дн. назад",
"loadError": "Ошибка загрузки данных дашборда"
},
"users": {
"pageName": "Управление пользователями",
@@ -76,9 +78,21 @@
"edit": "Редактировать пользователя",
"delete": "Удалить пользователя",
"you": "(Вы)",
"cannotChangeOwnRole": "Вы не можете изменить свою собственную роль",
"confirmDelete": "Удалить пользователя",
"deleteConfirmation": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо."
"deleteConfirmation": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо.",
"statusUpdated": "Статус пользователя обновлён",
"statusUpdateError": "Не удалось обновить статус",
"passwordRequired": "Пароль обязателен для нового пользователя",
"createSuccess": "Пользователь создан",
"createError": "Ошибка создания пользователя",
"updateSuccess": "Пользователь обновлён",
"updateError": "Ошибка обновления пользователя",
"deleteSuccess": "Пользователь удалён",
"deleteError": "Ошибка удаления пользователя",
"cannotChangeOwnRole": "Вы не можете изменить свою роль",
"noUsers": "Пользователи не найдены",
"loadError": "Ошибка загрузки списка пользователей",
"loadCurrentError": "Ошибка загрузки информации о текущем пользователе"
},
"restaurants": {
"pageName": "Рестораны",
@@ -92,7 +106,7 @@
"noRestaurants": "Ресторанов не найдено. Нажмите \"Добавить ресторан\", чтобы создать его.",
"deleteConfirmation": "Вы уверены, что хотите удалить этот ресторан? Это действие необратимо.",
"check": "Проверить подключение",
"checkError": "Ошибка",
"checkError": "Ошибка проверки: {error}",
"loadError": "Ошибка загрузки списка ресторанов",
"createSuccess": "Ресторан успешно создан",
"updateSuccess": "Ресторан успешно обновлён",
@@ -109,7 +123,10 @@
"saved": "Настройки успешно сохранены",
"saveFailed": "Не удалось сохранить настройки",
"loadFailed": "Не удалось загрузить метаданные настроек",
"enabled": "Включено"
"enabled": "Включено",
"saveSuccess": "Настройки сохранены",
"saveError": "Ошибка сохранения настроек",
"loadMetaError": "Ошибка загрузки метаданных"
},
"profile": {
"title": "Мой профиль",
@@ -122,9 +139,9 @@
"save": "Сохранить изменения",
"reset": "Сбросить",
"role": "Роль",
"passwordMismatch": "Пароли не совпадают",
"updateSuccess": "Профиль успешно обновлен",
"updateFailed": "Не удалось обновить профиль"
"passwordsMismatch": "Пароли не совпадают",
"updateSuccess": "Профиль обновлён",
"updateError": "Ошибка обновления профиля"
},
"login": {
"title": "С возвращением",

View File

@@ -70,7 +70,9 @@
import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
import { useNotification } from '../composables/useNotification'
const { showNotification } = useNotification()
const { t, locale } = useI18n()
interface FieldMeta {
key: string;
@@ -92,7 +94,7 @@ async function loadMeta() {
if (res.ok) {
meta.value = await res.json();
} else {
showMessage('Failed to load settings metadata', 'bg-red-50 text-red-800');
showNotification('settings.loadMetaError', 'error');
}
}
@@ -101,7 +103,7 @@ async function loadValues() {
if (res.ok) {
values.value = await res.json();
} else {
showMessage('Failed to load settings values', 'bg-red-50 text-red-800');
showNotification('settings.loadMetaError', 'error');
}
}
@@ -117,12 +119,12 @@ async function saveSettings() {
body: JSON.stringify(values.value),
});
if (res.ok) {
showMessage('Settings saved successfully', 'bg-green-50 text-green-800');
showNotification('settings.saveSuccess', 'success');
} else {
showMessage('Failed to save settings', 'bg-red-50 text-red-800');
showNotification('settings.saveError', 'error');
}
} catch (e) {
showMessage('Network error', 'bg-red-50 text-red-800');
showNotification('common.networkError', 'error');
}
}

View File

@@ -178,7 +178,9 @@ import { ref, onMounted, onUnmounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import { useNotification } from '../composables/useNotification'
const { showNotification } = useNotification()
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' });
const userGrowth = ref(12);
const sessionGrowth = ref(5);
@@ -222,6 +224,7 @@ async function loadDashboardData() {
}));
}
} catch (e) {
showNotification('dashboard.loadError', 'error');
console.error('Failed to load dashboard data', e);
}
}

View File

@@ -77,19 +77,6 @@
</div>
</div>
</form>
<!-- Notification Toast -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</div>
</div>
</AppLayout>
@@ -112,18 +99,10 @@ const form = reactive({
});
const loading = ref(false);
const notification = ref({ show: false, type: 'success', message: '' });
const userInitials = computed(() => (userStore.login[0] || 'U').toUpperCase());
const passwordMismatch = computed(() => !!form.password && form.password !== form.confirmPassword);
function showNotification(message: string, type: 'success' | 'error') {
notification.value = { show: true, type, message };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
function resetForm() {
form.email = userStore.email;
form.password = '';
@@ -133,7 +112,7 @@ function resetForm() {
async function saveProfile() {
if (form.password && form.password !== form.confirmPassword) {
showNotification('Passwords do not match', 'error');
showNotification('profile.passwordsMismatch', 'error');
return;
}
@@ -149,10 +128,10 @@ async function saveProfile() {
if (ok) {
locale.value = form.language;
showNotification('Profile updated successfully', 'success');
showNotification('profile.updateSuccess', 'success');
resetForm(); // очищаем поля пароля
} else {
showNotification('Failed to update profile', 'error');
showNotification('profile.updateError', 'error');
}
}
@@ -161,15 +140,3 @@ onMounted(() => {
form.language = userStore.language;
});
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -31,12 +31,7 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
:checked="rest.https"
@change="toggleHttps(rest)"
class="sr-only peer"
/>
<input type="checkbox" :checked="rest.https" @change="toggleHttps(rest)" class="sr-only peer" />
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</label>
@@ -45,13 +40,7 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-3">
<!-- Кнопка проверки -->
<button
@click="checkRestaurant(rest)"
:disabled="rest.checking"
class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50"
:title="t('restaurants.check')"
>
<button @click="checkRestaurant(rest)" :disabled="rest.checking" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50">
<svg v-if="!rest.checking" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -60,20 +49,17 @@
<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>
</button>
<!-- Кнопка редактирования -->
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800 transition-colors">
<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="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="confirmDelete(rest.id)" class="text-red-600 hover:text-red-800 transition-colors">
<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="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>
<!-- Результат проверки только при успехе (ms) -->
<span v-if="rest.checkResult" class="text-xs text-gray-500 ml-1 whitespace-nowrap">
<span v-if="rest.checkResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
{{ rest.checkResult }}
</span>
</div>
@@ -162,28 +148,17 @@
</div>
</div>
</Transition>
<!-- Уведомления (toast) -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
const { t } = useI18n()
const { t } = useI18n();
const { showNotification } = useNotification();
type Restaurant = {
id: number;
@@ -203,29 +178,18 @@ const form = ref({ id: null, name: '', login: '', password: '', host: '', https:
const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
// Уведомления
const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' });
function showNotification(message: string, type: 'success' | 'error') {
notification.value = { show: true, type, message };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
async function loadRestaurants() {
try {
const res = await fetch('/api/admin/restaurants');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) throw new Error();
const data = await res.json();
restaurants.value = data.map((r: any) => ({
...r,
checking: false,
checkResult: null
}));
} catch (e: any) {
showNotification(t('restaurants.loadError'), 'error');
restaurants.value = [];
} catch (e) {
showNotification('restaurants.loadError', 'error');
}
}
@@ -236,53 +200,25 @@ function formatDate(dateStr: string) {
async function checkRestaurant(rest: Restaurant) {
rest.checking = true;
rest.checkResult = null; // сбрасываем
rest.checkResult = null;
try {
const response = await fetch(`/api/admin/restaurants/${rest.id}/check`);
const data = await response.json();
if (data.success) {
rest.checkResult = `${data.latency_ms} ms`; // показываем в таблице
rest.checkResult = `${data.latency_ms} ms`;
} else {
const errorText = data.error || 'Unknown error';
showNotification(`${t('restaurants.checkError')}: ${errorText}`, 'error');
// rest.checkResult остаётся null -> ничего не показываем
showNotification('restaurants.checkError', 'error', { error: errorText });
}
} catch (error: any) {
const msg = t('restaurants.checkNetworkError');
showNotification(`${msg}: ${error.message}`, 'error');
// rest.checkResult остаётся null
showNotification('restaurants.checkNetworkError', 'error', { error: error.message });
} finally {
rest.checking = false;
}
}
function openModal(mode: 'create' | 'edit', rest: Restaurant | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
modalTitle.value = t('restaurants.add');
} else {
if (rest) {
form.value = {
id: rest.id,
name: rest.name,
login: rest.login,
password: '',
host: rest.host,
https: rest.https || false
};
modalTitle.value = t('restaurants.edit');
}
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function toggleHttps(rest: Restaurant) {
const newHttps = !rest.https;
const payload = {
@@ -299,19 +235,41 @@ async function toggleHttps(rest: Restaurant) {
});
if (res.ok) {
rest.https = newHttps;
showNotification(t('restaurants.httpsUpdateSuccess'), 'success');
showNotification('restaurants.httpsUpdateSuccess', 'success');
} else {
const errText = await res.text();
showNotification(`${t('restaurants.httpsUpdateError')}: ${errText}`, 'error');
showNotification('restaurants.httpsUpdateError', 'error');
}
} catch (e: any) {
showNotification(`${t('restaurants.httpsUpdateError')}: ${e.message}`, 'error');
} catch (e) {
showNotification('restaurants.httpsUpdateError', 'error');
}
}
function openModal(mode: 'create' | 'edit', rest: Restaurant | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
modalTitle.value = t('restaurants.add');
} else if (rest) {
form.value = {
id: rest.id,
name: rest.name,
login: rest.login,
password: '',
host: rest.host,
https: rest.https || false
};
modalTitle.value = t('restaurants.edit');
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function submitRestaurant() {
if (modalMode.value === 'create' && !form.value.password) {
showNotification(t('restaurants.passwordRequired'), 'error');
showNotification('restaurants.passwordRequired', 'error');
return;
}
@@ -323,28 +281,27 @@ async function submitRestaurant() {
login: form.value.login,
...(form.value.password ? { password: form.value.password } : {})
};
let response;
if (modalMode.value === 'create') {
response = await fetch('/api/admin/restaurants', {
const res = await fetch('/api/admin/restaurants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Create failed');
showNotification(t('restaurants.createSuccess'), 'success');
if (!res.ok) throw new Error();
showNotification('restaurants.createSuccess', 'success');
} else {
response = await fetch(`/api/admin/restaurants/${form.value.id}`, {
const res = await fetch(`/api/admin/restaurants/${form.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Update failed');
showNotification(t('restaurants.updateSuccess'), 'success');
if (!res.ok) throw new Error();
showNotification('restaurants.updateSuccess', 'success');
}
await loadRestaurants();
closeModal();
} catch (e: any) {
showNotification(e.message, 'error');
} catch (e) {
showNotification(modalMode.value === 'create' ? 'restaurants.createError' : 'restaurants.updateError', 'error');
}
}
@@ -355,11 +312,11 @@ function confirmDelete(id: number) {
async function deleteRestaurant(id: number) {
try {
const res = await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
showNotification(t('restaurants.deleteSuccess'), 'success');
if (!res.ok) throw new Error();
showNotification('restaurants.deleteSuccess', 'success');
await loadRestaurants();
} catch (e: any) {
showNotification(e.message, 'error');
} catch (e) {
showNotification('restaurants.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
}
@@ -383,13 +340,4 @@ onMounted(loadRestaurants);
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -68,19 +68,17 @@
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="8" class="px-6 py-12 text-center text-gray-500">No users found. Click "Add User" to create one.</td>
<td colspan="8" class="px-6 py-12 text-center text-gray-500">{{ t('users.noUsers') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модалка создания/редактирования пользователя -->
<!-- Modal for create/edit user -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<!-- Затемнение с размытием -->
<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 transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
@@ -129,7 +127,7 @@
</div>
</Transition>
<!-- Модалка подтверждения удаления -->
<!-- Delete confirmation modal -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
@@ -159,26 +157,15 @@
import { ref, onMounted, computed } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import { useUserStore } from '../stores/user';
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
const userStore = useUserStore();
const currentUserId = ref<number | null>(null);
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
}
} catch (e) {
console.error('Failed to load current user', e);
}
}
const users = ref([]);
const users = ref<any[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({ id: null, login: '', email: '', password: '', role: 'user' });
@@ -189,9 +176,28 @@ const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === currentUserId.value;
});
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
} else {
showNotification('users.loadCurrentError', 'error');
}
} catch (e) {
showNotification('common.networkError', 'error');
}
}
async function loadUsers() {
const res = await fetch('/api/admin/users');
users.value = await res.json();
try {
const res = await fetch('/api/admin/users');
if (!res.ok) throw new Error();
users.value = await res.json();
} catch (e) {
showNotification('users.loadError', 'error');
}
}
function formatDate(dateStr: string) {
@@ -200,8 +206,14 @@ function formatDate(dateStr: string) {
}
async function toggleActive(user: any) {
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
await loadUsers();
try {
const res = await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
if (!res.ok) throw new Error();
await loadUsers();
showNotification('users.statusUpdated', 'success');
} catch (e) {
showNotification('users.statusUpdateError', 'error');
}
}
function openModal(mode: 'create' | 'edit', user: any = null) {
@@ -222,7 +234,12 @@ function closeModal() {
async function submitUser() {
if (isEditingSelf.value && form.value.role !== userStore.role) {
alert('You cannot change your own role');
showNotification('users.cannotChangeOwnRole', 'error');
return;
}
if (modalMode.value === 'create' && !form.value.password) {
showNotification('users.passwordRequired', 'error');
return;
}
@@ -235,29 +252,30 @@ async function submitUser() {
if (form.value.password) {
payload.password = form.value.password;
}
let response;
if (modalMode.value === 'create') {
if (!form.value.password) {
alert('Password is required');
return;
}
const res = await fetch('/api/admin/users', {
response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Create failed');
if (!response.ok) throw new Error();
showNotification('users.createSuccess', 'success');
} else {
const res = await fetch(`/api/admin/users/${form.value.id}`, {
response = await fetch(`/api/admin/users/${form.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Update failed');
if (!response.ok) throw new Error();
showNotification('users.updateSuccess', 'success');
}
await loadUsers();
closeModal();
} catch (e) {
alert('Operation failed: ' + e.message);
showNotification(modalMode.value === 'create' ? 'users.createError' : 'users.updateError', 'error');
}
}
@@ -266,9 +284,16 @@ function confirmDelete(id: number) {
}
async function deleteUser(id: number) {
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
await loadUsers();
deleteConfirm.value.show = false;
try {
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('users.deleteSuccess', 'success');
await loadUsers();
} catch (e) {
showNotification('users.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
}
}
onMounted(async () => {