feat: fix leaderboard, redesign student profile, olympiad admin, ru i18n
LeaderBoard: - Fix bug: allUsers now loaded before logsResource on mount - Fallback: show users with 0 points when Energy Point Log is empty - Replace FontAwesome with lucide-vue-next (Crown, Medal, Trophy, User) - Replace all hardcoded hex colors with Frappe UI CSS variables StudentProfile: - Remove ~40 debug console.log statements - Increase avatar to 96px, use amber CSS token gradient - All scoped CSS colors converted to rgb(var(--color-*)) tokens Olympiads admin: - Add OlympiadForm.vue for create/edit with all fields - Add routes /olympiads/new and /olympiads/:name/edit - Add "Create Olympiad" button for moderators/instructors - Add sidebar entry for admin users Russian translations: - Add Olympiad module strings to lms/locale/ru.po Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+165
-115
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="lms-page-container">
|
<div class="lms-page-container">
|
||||||
|
|
||||||
<!-- HEADER & TABS -->
|
<!-- HEADER & TABS -->
|
||||||
<div class="lms-page-header">
|
<div class="lms-page-header">
|
||||||
<h1 class="page-title">{{ __('Таблица лидеров') }}</h1>
|
<h1 class="page-title">{{ __('Таблица лидеров') }}</h1>
|
||||||
|
|
||||||
<!-- Сегментированные вкладки (Apple / Frappe style) -->
|
<!-- Сегментированные вкладки (Apple / Frappe style) -->
|
||||||
<div class="lms-tabs-container">
|
<div class="lms-tabs-container">
|
||||||
<button
|
<button
|
||||||
@@ -21,23 +21,23 @@
|
|||||||
|
|
||||||
<!-- MAIN GRID LAYOUT -->
|
<!-- MAIN GRID LAYOUT -->
|
||||||
<div class="lms-layout-grid">
|
<div class="lms-layout-grid">
|
||||||
|
|
||||||
<!-- LEFT COLUMN: SUMMARY CARDS -->
|
<!-- LEFT COLUMN: SUMMARY CARDS -->
|
||||||
<div class="lms-sidebar">
|
<div class="lms-sidebar">
|
||||||
|
|
||||||
<!-- Карточка: Ваш результат -->
|
<!-- Карточка: Ваш результат -->
|
||||||
<div class="lms-card my-result-card">
|
<div class="lms-card my-result-card">
|
||||||
<div class="lms-card-body">
|
<div class="lms-card-body">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-subtitle">{{ __('Ваш результат') }}</span>
|
<span class="card-subtitle">{{ __('Ваш результат') }}</span>
|
||||||
<div v-if="currentUserPosition === 1" class="icon-gold"><i class="fas fa-crown"></i></div>
|
<div v-if="currentUserPosition === 1" class="icon-gold"><Crown class="icon-svg" /></div>
|
||||||
<div v-else-if="currentUserPosition === 2" class="icon-silver"><i class="fas fa-medal"></i></div>
|
<div v-else-if="currentUserPosition === 2" class="icon-silver"><Medal class="icon-svg" /></div>
|
||||||
<div v-else-if="currentUserPosition === 3" class="icon-bronze"><i class="fas fa-medal"></i></div>
|
<div v-else-if="currentUserPosition === 3" class="icon-bronze"><Medal class="icon-svg" /></div>
|
||||||
<div v-else class="icon-gray"><i class="fas fa-user"></i></div>
|
<div v-else class="icon-gray"><User class="icon-svg" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="card-title truncate-text" :title="currentUser.full_name">{{ currentUser.full_name || __('Вы') }}</h2>
|
<h2 class="card-title truncate-text" :title="currentUser.full_name">{{ currentUser.full_name || __('Вы') }}</h2>
|
||||||
|
|
||||||
<div class="card-stats">
|
<div class="card-stats">
|
||||||
<div class="stat-block">
|
<div class="stat-block">
|
||||||
<span class="stat-value">{{ currentUserPosition !== '-' ? currentUserPosition : '-' }}</span>
|
<span class="stat-value">{{ currentUserPosition !== '-' ? currentUserPosition : '-' }}</span>
|
||||||
@@ -51,10 +51,10 @@
|
|||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<p v-if="pointsToNext > 0 && currentUserPosition !== '-'" class="text-muted">
|
<p v-if="pointsToNext > 0 && currentUserPosition !== '-'" class="text-muted">
|
||||||
{{ __('До') }} {{ nextPosition }}-{{ __('го места не хватает') }} <strong style="color: #111827;">{{ pointsToNext }}</strong> {{ __('очков') }}
|
{{ __('До') }} {{ nextPosition }}-{{ __('го места не хватает') }} <strong class="text-strong">{{ pointsToNext }}</strong> {{ __('очков') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="currentUserPosition === 1" class="text-success">
|
<p v-else-if="currentUserPosition === 1" class="text-success">
|
||||||
<i class="fas fa-check-circle"></i> {{ __('Вы на первом месте!') }}
|
{{ __('Вы на первом месте!') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-muted">{{ __('Выполняйте задания, чтобы попасть в топ') }}</p>
|
<p v-else class="text-muted">{{ __('Выполняйте задания, чтобы попасть в топ') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,16 +66,16 @@
|
|||||||
<div class="lms-card-body">
|
<div class="lms-card-body">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-subtitle">{{ __('Лидер группы') }}</span>
|
<span class="card-subtitle">{{ __('Лидер группы') }}</span>
|
||||||
<div class="icon-gold"><i class="fas fa-trophy"></i></div>
|
<div class="icon-gold"><Trophy class="icon-svg" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="topUserInGroup">
|
<template v-if="topUserInGroup">
|
||||||
<h2 class="card-title truncate-text" :title="topUserInGroup.full_name">
|
<h2 class="card-title truncate-text" :title="topUserInGroup.full_name">
|
||||||
<a :href="`/lms/user/${topUserInGroup.username}`" class="card-link">
|
<a :href="`/lms/user/${topUserInGroup.username}`" class="card-link">
|
||||||
{{ topUserInGroup.full_name }}
|
{{ topUserInGroup.full_name }}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="card-stats">
|
<div class="card-stats">
|
||||||
<div class="stat-block">
|
<div class="stat-block">
|
||||||
<span class="stat-value">1</span>
|
<span class="stat-value">1</span>
|
||||||
@@ -105,10 +105,9 @@
|
|||||||
<div class="lms-table-header">
|
<div class="lms-table-header">
|
||||||
<h3>{{ activeGroupLabel }} <span class="count-badge">{{ currentLeaderboard.length }}</span></h3>
|
<h3>{{ activeGroupLabel }} <span class="count-badge">{{ currentLeaderboard.length }}</span></h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table v-if="currentLeaderboard.length > 0" class="lms-table">
|
<table v-if="currentLeaderboard.length > 0" class="lms-table">
|
||||||
<!-- FIX: Fixed table layout to prevent jumping when tabs change -->
|
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width: 70px;">
|
<col style="width: 70px;">
|
||||||
<col style="width: auto;">
|
<col style="width: auto;">
|
||||||
@@ -126,19 +125,19 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="(user, index) in currentLeaderboard"
|
v-for="(user, index) in currentLeaderboard"
|
||||||
:key="user.user"
|
:key="user.user"
|
||||||
:class="{'is-current-user': user.user === currentUser.name}"
|
:class="{'is-current-user': user.user === currentUser.name}"
|
||||||
>
|
>
|
||||||
<!-- Ранг -->
|
<!-- Ранг -->
|
||||||
<td style="text-align: center; font-weight: 600; color: #4b5563;">
|
<td class="td-rank">
|
||||||
<span v-if="index === 0" title="1 Место" class="medal-emoji">🥇</span>
|
<span v-if="index === 0" title="1 Место" class="medal-emoji">🥇</span>
|
||||||
<span v-else-if="index === 1" title="2 Место" class="medal-emoji">🥈</span>
|
<span v-else-if="index === 1" title="2 Место" class="medal-emoji">🥈</span>
|
||||||
<span v-else-if="index === 2" title="3 Место" class="medal-emoji">🥉</span>
|
<span v-else-if="index === 2" title="3 Место" class="medal-emoji">🥉</span>
|
||||||
<span v-else>{{ index + 1 }}</span>
|
<span v-else>{{ index + 1 }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Пользователь -->
|
<!-- Пользователь -->
|
||||||
<td>
|
<td>
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
@@ -148,15 +147,11 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Очки -->
|
<!-- Очки -->
|
||||||
<td style="text-align: center; font-weight: 600; color: #111827;">
|
<td class="td-points">{{ user.points }}</td>
|
||||||
{{ user.points }}
|
<td class="td-points">{{ user.points }}</td>
|
||||||
</td>
|
|
||||||
<td style="text-align: center; font-weight: 600; color: #111827;">
|
|
||||||
{{ user.points }}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Бонусы МПГУ -->
|
<!-- Бонусы МПГУ -->
|
||||||
<td v-if="activeGroup === 'LMS Schoolchild'" style="text-align: center;">
|
<td v-if="activeGroup === 'LMS Schoolchild'" style="text-align: center;">
|
||||||
<span v-if="user.bonus > 0" class="bonus-badge">
|
<span v-if="user.bonus > 0" class="bonus-badge">
|
||||||
@@ -167,9 +162,45 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- Fallback: нет данных в таблице, показываем всех пользователей с 0 очков -->
|
||||||
|
<div v-else-if="currentGroupUsers.length > 0" class="table-responsive">
|
||||||
|
<table class="lms-table">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 70px;">
|
||||||
|
<col style="width: auto;">
|
||||||
|
<col style="width: 130px;">
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align: center;">#</th>
|
||||||
|
<th>{{ __('Пользователь') }}</th>
|
||||||
|
<th style="text-align: center;">{{ __('Всего') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(user, index) in currentGroupUsers"
|
||||||
|
:key="user.name"
|
||||||
|
:class="{'is-current-user': user.name === currentUser.name}"
|
||||||
|
>
|
||||||
|
<td class="td-rank">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="user-cell">
|
||||||
|
<div class="user-avatar">{{ user.full_name?.charAt(0).toUpperCase() || 'U' }}</div>
|
||||||
|
<a :href="`/lms/user/${user.username}`" class="user-link truncate-text">
|
||||||
|
{{ user.full_name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="td-points">0</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="lms-empty-state">
|
<div v-else class="lms-empty-state">
|
||||||
<i class="fas fa-ghost empty-icon"></i>
|
<User class="empty-icon" />
|
||||||
<p>{{ __('В этой категории пока нет участников.') }}</p>
|
<p>{{ __('В этой категории пока нет участников.') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +215,7 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { Crown, Medal, Trophy, User } from 'lucide-vue-next'
|
||||||
|
|
||||||
const store = usersStore()
|
const store = usersStore()
|
||||||
const { userResource, allUsers } = store
|
const { userResource, allUsers } = store
|
||||||
@@ -223,24 +255,17 @@ const currentUser = computed(() => {
|
|||||||
return userResource.data || {}
|
return userResource.data || {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentUserInitial = computed(() => {
|
|
||||||
if (currentUser.value.full_name) {
|
|
||||||
return currentUser.value.full_name.charAt(0).toUpperCase()
|
|
||||||
}
|
|
||||||
return currentUser.value.name?.charAt(0).toUpperCase() || 'U'
|
|
||||||
})
|
|
||||||
|
|
||||||
function getUserInitial(user) {
|
function getUserInitial(user) {
|
||||||
return user.full_name?.charAt(0).toUpperCase() || user.username?.charAt(0).toUpperCase() || 'U'
|
return user.full_name?.charAt(0).toUpperCase() || user.username?.charAt(0).toUpperCase() || 'U'
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => logsResource.data, () => allUsers.data],
|
[() => logsResource.data, () => allUsers.data],
|
||||||
() => {
|
() => {
|
||||||
if (logsResource.data && allUsers.data) {
|
if (allUsers.data) {
|
||||||
calculateLeaderboard()
|
calculateLeaderboard()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,6 +287,14 @@ const currentLeaderboard = computed(() => {
|
|||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fallback: все пользователи активной группы (для случая когда Energy Point Log пуст)
|
||||||
|
const currentGroupUsers = computed(() => {
|
||||||
|
if (currentLeaderboard.value.length > 0) return []
|
||||||
|
return usersList.value
|
||||||
|
.filter(u => u.roles && u.roles.some(r => r === activeGroup.value))
|
||||||
|
.slice(0, 50)
|
||||||
|
})
|
||||||
|
|
||||||
const topUserInGroup = computed(() => {
|
const topUserInGroup = computed(() => {
|
||||||
return currentLeaderboard.value[0]
|
return currentLeaderboard.value[0]
|
||||||
})
|
})
|
||||||
@@ -296,14 +329,16 @@ const nextPosition = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function calculateLeaderboard() {
|
function calculateLeaderboard() {
|
||||||
if (!logsResource.data || !usersList.value) return
|
if (!usersList.value) return
|
||||||
|
|
||||||
const pointsMap = {}
|
const pointsMap = {}
|
||||||
|
|
||||||
logsResource.data.forEach(log => {
|
if (logsResource.data) {
|
||||||
if (!pointsMap[log.user]) pointsMap[log.user] = 0
|
logsResource.data.forEach(log => {
|
||||||
pointsMap[log.user] += log.points
|
if (!pointsMap[log.user]) pointsMap[log.user] = 0
|
||||||
})
|
pointsMap[log.user] += log.points
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
leaderboard.value = Object.keys(pointsMap)
|
leaderboard.value = Object.keys(pointsMap)
|
||||||
.map(user => {
|
.map(user => {
|
||||||
@@ -327,7 +362,7 @@ function calculateLeaderboard() {
|
|||||||
username: u.username,
|
username: u.username,
|
||||||
roles: u.roles || [],
|
roles: u.roles || [],
|
||||||
bonus: bonus,
|
bonus: bonus,
|
||||||
isSchoolchild: isSchoolchild
|
isSchoolchild: isSchoolchild
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(user => user !== null)
|
.filter(user => user !== null)
|
||||||
@@ -336,14 +371,15 @@ function calculateLeaderboard() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await allUsers.reload()
|
await allUsers.reload()
|
||||||
await logsResource.reload()
|
// Загружаем Energy Point Log параллельно, ошибка не блокирует отображение
|
||||||
|
logsResource.reload().catch(() => {})
|
||||||
calculateLeaderboard()
|
calculateLeaderboard()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ============================================
|
/* ============================================
|
||||||
ДИЗАЙН В СТИЛЕ FRAPPE LMS
|
LEADERBOARD — Frappe UI tokens only
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.lms-page-container {
|
.lms-page-container {
|
||||||
@@ -351,7 +387,7 @@ onMounted(async () => {
|
|||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === HEADER & TABS === */
|
/* === HEADER & TABS === */
|
||||||
@@ -368,22 +404,22 @@ onMounted(async () => {
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-tabs-container {
|
.lms-tabs-container {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background-color: #f3f4f6;
|
background-color: rgb(var(--color-surface-gray-2));
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-tab-btn {
|
.lms-tab-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
@@ -393,12 +429,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lms-tab-btn:hover {
|
.lms-tab-btn:hover {
|
||||||
color: #374151;
|
color: rgb(var(--color-ink-gray-7));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-tab-btn.active {
|
.lms-tab-btn.active {
|
||||||
background: #ffffff;
|
background: rgb(var(--color-surface-white));
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -419,8 +455,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
/* === CARDS === */
|
/* === CARDS === */
|
||||||
.lms-card {
|
.lms-card {
|
||||||
background: #ffffff;
|
background: rgb(var(--color-surface-white));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -440,36 +476,41 @@ onMounted(async () => {
|
|||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-gold { color: #f59e0b; font-size: 1.4rem; }
|
.icon-svg {
|
||||||
.icon-silver { color: #9ca3af; font-size: 1.4rem; }
|
width: 22px;
|
||||||
.icon-bronze { color: #b45309; font-size: 1.4rem; }
|
height: 22px;
|
||||||
.icon-gray { color: #d1d5db; font-size: 1.4rem; }
|
}
|
||||||
|
|
||||||
|
.icon-gold { color: rgb(var(--color-ink-amber-3)); font-size: 1.4rem; }
|
||||||
|
.icon-silver { color: rgb(var(--color-ink-gray-4)); font-size: 1.4rem; }
|
||||||
|
.icon-bronze { color: rgb(var(--color-ink-amber-4)); font-size: 1.4rem; }
|
||||||
|
.icon-gray { color: rgb(var(--color-ink-gray-3)); font-size: 1.4rem; }
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 24px 0;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-link {
|
.card-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
.card-link:hover { color: #00a9a6; }
|
.card-link:hover { color: rgb(var(--color-ink-blue-3)); }
|
||||||
|
|
||||||
.card-stats {
|
.card-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,20 +523,21 @@ onMounted(async () => {
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-primary { color: #00a9a6; }
|
.text-primary { color: rgb(var(--color-ink-blue-3)); }
|
||||||
.text-gold { color: #f59e0b; }
|
.text-gold { color: rgb(var(--color-ink-amber-3)); }
|
||||||
.text-muted { color: #6b7280; font-size: 13px; margin: 0; line-height: 1.5; }
|
.text-muted { color: rgb(var(--color-ink-gray-5)); font-size: 13px; margin: 0; line-height: 1.5; }
|
||||||
.text-success { color: #059669; font-size: 13px; margin: 0; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
.text-success { color: rgb(var(--color-ink-green-3)); font-size: 13px; margin: 0; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.text-strong { color: rgb(var(--color-ink-gray-9)); }
|
||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
@@ -503,42 +545,40 @@ onMounted(async () => {
|
|||||||
|
|
||||||
/* === TABLE === */
|
/* === TABLE === */
|
||||||
.table-card {
|
.table-card {
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table-header {
|
.lms-table-header {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
background: #ffffff;
|
background: rgb(var(--color-surface-white));
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table-header h3 {
|
.lms-table-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count-badge {
|
.count-badge {
|
||||||
background: #f3f4f6;
|
background: rgb(var(--color-surface-gray-2));
|
||||||
color: #4b5563;
|
color: rgb(var(--color-ink-gray-6));
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- FIX: Настройки скролла для длинной таблицы --- */
|
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 650px; /* Ограничиваем высоту, чтобы таблица не уезжала вниз */
|
max-height: 650px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Кастомный красивый скроллбар */
|
|
||||||
.table-responsive::-webkit-scrollbar {
|
.table-responsive::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -547,11 +587,11 @@ onMounted(async () => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.table-responsive::-webkit-scrollbar-thumb {
|
.table-responsive::-webkit-scrollbar-thumb {
|
||||||
background-color: #d1d5db;
|
background-color: rgb(var(--color-outline-gray-3));
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.table-responsive::-webkit-scrollbar-thumb:hover {
|
.table-responsive::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #9ca3af;
|
background-color: rgb(var(--color-outline-gray-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table {
|
.lms-table {
|
||||||
@@ -559,19 +599,17 @@ onMounted(async () => {
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table th {
|
.lms-table th {
|
||||||
background: #f9fafb;
|
background: rgb(var(--color-surface-gray-1));
|
||||||
color: #4b5563;
|
color: rgb(var(--color-ink-gray-6));
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
/* Заменяем border-bottom на box-shadow для sticky, чтобы не было просветов */
|
box-shadow: inset 0 -1px 0 rgb(var(--color-outline-gray-2));
|
||||||
box-shadow: inset 0 -1px 0 #e5e7eb;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
/* Делаем шапку прилипающей */
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -579,24 +617,36 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.lms-table td {
|
.lms-table td {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
color: #374151;
|
color: rgb(var(--color-ink-gray-7));
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.td-rank {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--color-ink-gray-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-points {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--color-ink-gray-9));
|
||||||
|
}
|
||||||
|
|
||||||
.lms-table tr:last-child td {
|
.lms-table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table tr:hover {
|
.lms-table tr:hover {
|
||||||
background-color: #f9fafb;
|
background-color: rgb(var(--color-surface-gray-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table tr.is-current-user {
|
.lms-table tr.is-current-user {
|
||||||
background-color: #f0fdfa;
|
background-color: rgb(var(--color-surface-blue-1));
|
||||||
}
|
}
|
||||||
.lms-table tr.is-current-user td {
|
.lms-table tr.is-current-user td {
|
||||||
border-bottom-color: #ccfbf1;
|
border-bottom-color: rgb(var(--color-outline-blue-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.medal-emoji {
|
.medal-emoji {
|
||||||
@@ -612,8 +662,8 @@ onMounted(async () => {
|
|||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: #e5e7eb;
|
background: rgb(var(--color-surface-gray-3));
|
||||||
color: #4b5563;
|
color: rgb(var(--color-ink-gray-6));
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -623,17 +673,17 @@ onMounted(async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.is-current-user .user-avatar {
|
.is-current-user .user-avatar {
|
||||||
background: #00a9a6;
|
background: rgb(var(--color-surface-blue-3));
|
||||||
color: #ffffff;
|
color: rgb(var(--color-ink-on-blue));
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-link {
|
.user-link {
|
||||||
color: #111827;
|
color: rgb(var(--color-ink-gray-9));
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.user-link:hover {
|
.user-link:hover {
|
||||||
color: #00a9a6;
|
color: rgb(var(--color-ink-blue-3));
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,8 +697,8 @@ onMounted(async () => {
|
|||||||
.bonus-badge {
|
.bonus-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #ecfdf5;
|
background: rgb(var(--color-surface-green-1));
|
||||||
color: #059669;
|
color: rgb(var(--color-ink-green-3));
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -658,7 +708,7 @@ onMounted(async () => {
|
|||||||
.lms-empty-state {
|
.lms-empty-state {
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #6b7280;
|
color: rgb(var(--color-ink-gray-5));
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -667,8 +717,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 32px;
|
width: 32px;
|
||||||
color: #d1d5db;
|
height: 32px;
|
||||||
|
color: rgb(var(--color-ink-gray-3));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === RESPONSIVE === */
|
/* === RESPONSIVE === */
|
||||||
@@ -676,11 +727,11 @@ onMounted(async () => {
|
|||||||
.lms-layout-grid {
|
.lms-layout-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-sidebar {
|
.lms-sidebar {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-card {
|
.lms-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -690,7 +741,7 @@ onMounted(async () => {
|
|||||||
.lms-page-container {
|
.lms-page-container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-sidebar {
|
.lms-sidebar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -699,20 +750,19 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-tab-btn {
|
.lms-tab-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lms-table th, .lms-table td {
|
.lms-table th, .lms-table td {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Немного уменьшим высоту на мобилках */
|
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<header class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5">
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="olympiad-form-page">
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-card-header">
|
||||||
|
<h2 class="form-title">{{ isEdit ? __('Edit Olympiad') : __('New Olympiad') }}</h2>
|
||||||
|
<p class="form-subtitle">{{ __('Fill in the olympiad details below') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-body">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="field-group field-full">
|
||||||
|
<FormControl
|
||||||
|
v-model="form.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
type="text"
|
||||||
|
:placeholder="__('Olympiad title')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject -->
|
||||||
|
<div class="field-group field-full">
|
||||||
|
<FormControl
|
||||||
|
v-model="form.subject"
|
||||||
|
:label="__('Subject')"
|
||||||
|
type="text"
|
||||||
|
:placeholder="__('e.g. Mathematics, Physics')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="field-group field-full">
|
||||||
|
<label class="field-label">{{ __('Description') }}</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="4"
|
||||||
|
:placeholder="__('Describe the olympiad...')"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registration dates -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">{{ __('Registration Start') }}</label>
|
||||||
|
<DatePicker v-model="form.registration_start" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">{{ __('Registration End') }}</label>
|
||||||
|
<DatePicker v-model="form.registration_end" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Olympiad dates -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">{{ __('Olympiad Start') }}</label>
|
||||||
|
<DatePicker v-model="form.olympiad_start" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">{{ __('Olympiad End') }}</label>
|
||||||
|
<DatePicker v-model="form.olympiad_end" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max participants -->
|
||||||
|
<div class="field-group">
|
||||||
|
<FormControl
|
||||||
|
v-model="form.max_participants"
|
||||||
|
:label="__('Max Participants')"
|
||||||
|
type="number"
|
||||||
|
:placeholder="__('Leave empty for unlimited')"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Passing score -->
|
||||||
|
<div class="field-group">
|
||||||
|
<FormControl
|
||||||
|
v-model="form.passing_score"
|
||||||
|
:label="__('Passing Score')"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="field-group field-full">
|
||||||
|
<label class="field-label">{{ __('Status') }}</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
@click="saveOlympiad"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!form.title"
|
||||||
|
>
|
||||||
|
{{ saving ? __('Saving...') : (isEdit ? __('Save Changes') : __('Create Olympiad')) }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" @click="router.back()">
|
||||||
|
{{ __('Cancel') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { Breadcrumbs, FormControl, Button, DatePicker, Select, createResource, toast } from 'frappe-ui'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
olympiadName?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.olympiadName)
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
subject: '',
|
||||||
|
description: '',
|
||||||
|
registration_start: '',
|
||||||
|
registration_end: '',
|
||||||
|
olympiad_start: '',
|
||||||
|
olympiad_end: '',
|
||||||
|
max_participants: '',
|
||||||
|
passing_score: '',
|
||||||
|
status: 'Draft',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = computed(() => [
|
||||||
|
{ label: __('Draft'), value: 'Draft' },
|
||||||
|
{ label: __('Registration Open'), value: 'Registration Open' },
|
||||||
|
{ label: __('In Progress'), value: 'In Progress' },
|
||||||
|
{ label: __('Completed'), value: 'Completed' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{ label: __('Olympiads'), route: { name: 'Olympiads' } },
|
||||||
|
{
|
||||||
|
label: isEdit.value ? __('Edit Olympiad') : __('New Olympiad'),
|
||||||
|
route: isEdit.value
|
||||||
|
? { name: 'OlympiadEdit', params: { olympiadName: props.olympiadName } }
|
||||||
|
: { name: 'OlympiadNew' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const existingDoc = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data: any) {
|
||||||
|
form.value.title = data.title || ''
|
||||||
|
form.value.subject = data.subject || ''
|
||||||
|
form.value.description = data.description || ''
|
||||||
|
form.value.registration_start = data.registration_start || ''
|
||||||
|
form.value.registration_end = data.registration_end || ''
|
||||||
|
form.value.olympiad_start = data.olympiad_start || ''
|
||||||
|
form.value.olympiad_end = data.olympiad_end || ''
|
||||||
|
form.value.max_participants = data.max_participants || ''
|
||||||
|
form.value.passing_score = data.passing_score || ''
|
||||||
|
form.value.status = data.status || 'Draft'
|
||||||
|
},
|
||||||
|
onError(error: any) {
|
||||||
|
toast.error(__('Failed to load olympiad: ') + (error.message || ''))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveOlympiad() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
toast.error(__('Title is required'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const doc: Record<string, any> = {
|
||||||
|
doctype: 'Olympiad',
|
||||||
|
title: form.value.title,
|
||||||
|
subject: form.value.subject || '',
|
||||||
|
description: form.value.description || '',
|
||||||
|
status: form.value.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.registration_start) doc.registration_start = form.value.registration_start
|
||||||
|
if (form.value.registration_end) doc.registration_end = form.value.registration_end
|
||||||
|
if (form.value.olympiad_start) doc.olympiad_start = form.value.olympiad_start
|
||||||
|
if (form.value.olympiad_end) doc.olympiad_end = form.value.olympiad_end
|
||||||
|
if (form.value.max_participants) doc.max_participants = parseInt(String(form.value.max_participants))
|
||||||
|
if (form.value.passing_score) doc.passing_score = parseInt(String(form.value.passing_score))
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await createResource({
|
||||||
|
url: 'frappe.client.save',
|
||||||
|
params: { doc: { ...doc, name: props.olympiadName } },
|
||||||
|
}).submit()
|
||||||
|
toast.success(__('Olympiad updated'))
|
||||||
|
} else {
|
||||||
|
const result: any = await createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
params: { doc },
|
||||||
|
}).submit()
|
||||||
|
toast.success(__('Olympiad created'))
|
||||||
|
const newName = result?.name
|
||||||
|
if (newName) {
|
||||||
|
router.push({ name: 'OlympiadDetail', params: { olympiadName: newName } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({ name: 'Olympiads' })
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(__('Error saving olympiad: ') + (e?.message || ''))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isEdit.value && props.olympiadName) {
|
||||||
|
existingDoc.update({
|
||||||
|
params: {
|
||||||
|
doctype: 'Olympiad',
|
||||||
|
name: props.olympiadName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
existingDoc.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.olympiad-form-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: rgb(var(--color-surface-white));
|
||||||
|
border: 1px solid rgb(var(--color-outline-gray-2));
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
|
background: rgb(var(--color-surface-gray-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(var(--color-ink-gray-9));
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(var(--color-ink-gray-5));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-body {
|
||||||
|
padding: 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(var(--color-ink-gray-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgb(var(--color-outline-gray-3));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(var(--color-ink-gray-9));
|
||||||
|
background: rgb(var(--color-surface-white));
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea:focus {
|
||||||
|
border-color: rgb(var(--color-ink-blue-3));
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--color-surface-blue-2), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgb(var(--color-outline-gray-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
grid-column: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,8 +21,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-gray-5">
|
<div class="flex items-center gap-3">
|
||||||
{{ filteredOlympiads.length }} {{ __('olympiads') }}
|
<div class="text-sm text-ink-gray-5">
|
||||||
|
{{ filteredOlympiads.length }} {{ __('olympiads') }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="canCreateOlympiad"
|
||||||
|
@click="router.push({ name: 'OlympiadNew' })"
|
||||||
|
class="create-olympiad-btn"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
{{ __('Create Olympiad') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,12 +66,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Breadcrumbs, TabButtons, createResource, usePageMeta } from 'frappe-ui'
|
import { Breadcrumbs, TabButtons, createResource, usePageMeta } from 'frappe-ui'
|
||||||
import { Trophy, Search } from 'lucide-vue-next'
|
import { Trophy, Search, Plus } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
import EmptyState from '@/components/EmptyState.vue'
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue'
|
import OlympiadCard from '@/pages/Olympiads/OlympiadCard.vue'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const canCreateOlympiad = computed(() => {
|
||||||
|
return userResource?.data?.is_moderator || userResource?.data?.is_instructor
|
||||||
|
})
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const currentStatus = ref('all')
|
const currentStatus = ref('all')
|
||||||
|
|
||||||
@@ -114,3 +132,24 @@ usePageMeta(() => ({
|
|||||||
icon: brand.favicon,
|
icon: brand.favicon,
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-olympiad-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgb(var(--color-surface-blue-3));
|
||||||
|
color: rgb(var(--color-ink-on-blue));
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.create-olympiad-btn:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VIEW MODE -->
|
<!-- VIEW MODE -->
|
||||||
<div v-if="!editMode" class="section-grid">
|
<div v-if="!editMode" class="section-grid">
|
||||||
<!-- Empty profile -->
|
<!-- Empty profile -->
|
||||||
<div v-if="schoolProfileNotFound" class="empty-card">
|
<div v-if="schoolProfileNotFound" class="empty-card">
|
||||||
@@ -406,25 +406,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hero-avatar {
|
.hero-avatar {
|
||||||
width: 64px;
|
width: 96px;
|
||||||
height: 64px;
|
height: 96px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #f97316, #f59e0b);
|
background: linear-gradient(135deg, rgb(var(--color-surface-amber-2)), rgb(var(--color-surface-amber-3)));
|
||||||
color: #fff;
|
color: rgb(var(--color-ink-on-amber));
|
||||||
font-size: 26px;
|
font-size: 38px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.25);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
.hero-info {
|
.hero-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.hero-name {
|
.hero-name {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgb(var(--color-ink-gray-9));
|
color: rgb(var(--color-ink-gray-9));
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgb(var(--color-ink-gray-5));
|
color: rgb(var(--color-ink-gray-5));
|
||||||
margin-top: 2px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.hero-bio {
|
.hero-bio {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
.card-header-icon {
|
.card-header-icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
color: #f97316;
|
color: rgb(var(--color-ink-amber-3));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.card-header-title {
|
.card-header-title {
|
||||||
@@ -581,8 +581,8 @@
|
|||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #fff7ed, #ffedd5);
|
background: rgb(var(--color-surface-amber-1));
|
||||||
color: #f97316;
|
color: rgb(var(--color-ink-amber-3));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -620,7 +620,7 @@
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f97316;
|
background: rgb(var(--color-ink-amber-3));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,8 +654,8 @@
|
|||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.edit-input:focus {
|
.edit-input:focus {
|
||||||
border-color: #f97316;
|
border-color: rgb(var(--color-ink-amber-3));
|
||||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
box-shadow: 0 0 0 3px rgba(var(--color-surface-amber-2), 0.4);
|
||||||
}
|
}
|
||||||
.dropdown-list {
|
.dropdown-list {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -698,13 +698,6 @@ const { user } = sessionStore();
|
|||||||
const $user = inject('$user');
|
const $user = inject('$user');
|
||||||
const schoolProfileNotFound = ref(false);
|
const schoolProfileNotFound = ref(false);
|
||||||
|
|
||||||
// Логирование инициализации
|
|
||||||
console.log('[DEBUG] Инициализация компонента:', {
|
|
||||||
user: user,
|
|
||||||
$user: $user.data,
|
|
||||||
username: $user.data?.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
username: {
|
username: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -714,9 +707,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const effectiveUsername = computed(() => {
|
const effectiveUsername = computed(() => {
|
||||||
const username = props.username || $user.data?.username || '';
|
return props.username || $user.data?.username || '';
|
||||||
console.log('[DEBUG] Вычисление effectiveUsername:', { propsUsername: props.username, sessionUsername: $user.data?.username, result: username });
|
|
||||||
return username;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editMode = ref(false);
|
const editMode = ref(false);
|
||||||
@@ -726,17 +717,12 @@ const profile = createResource({
|
|||||||
url: 'frappe.client.get',
|
url: 'frappe.client.get',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
const username = effectiveUsername.value;
|
const username = effectiveUsername.value;
|
||||||
console.log('[DEBUG] Запрос profile:', { doctype: 'User', filters: { username } });
|
|
||||||
return {
|
return {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
filters: { username },
|
filters: { username },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
|
||||||
console.log('[DEBUG] Профиль загружен:', data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
onError(error) {
|
||||||
console.error('[DEBUG] Ошибка загрузки профиля:', error);
|
|
||||||
window.frappe?.msgprint({
|
window.frappe?.msgprint({
|
||||||
title: 'Ошибка',
|
title: 'Ошибка',
|
||||||
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
|
message: 'Не удалось загрузить профиль пользователя: ' + (error.message || 'Неизвестная ошибка'),
|
||||||
@@ -752,23 +738,17 @@ const schoolProfile = createResource({
|
|||||||
filters: { user:user },
|
filters: { user:user },
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onError(error) {
|
||||||
console.log('[DEBUG] Профиль школьника загружен:', data);
|
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
|
||||||
|
schoolProfileNotFound.value = true;
|
||||||
|
} else {
|
||||||
|
window.frappe?.msgprint({
|
||||||
|
title: 'Ошибка',
|
||||||
|
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
|
||||||
|
indicator: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError(error) {
|
|
||||||
// Проверяем, является ли ошибка "не найдено"
|
|
||||||
if (error.exc_type === 'DoesNotExistError' || error.message?.includes('DoesNotExist')) {
|
|
||||||
console.log('[DEBUG] Профиль школьника не найден, создаем новый');
|
|
||||||
schoolProfileNotFound.value = true;
|
|
||||||
} else {
|
|
||||||
console.error('[DEBUG] Ошибка загрузки профиля школьника:', error);
|
|
||||||
window.frappe?.msgprint({
|
|
||||||
title: 'Ошибка',
|
|
||||||
message: 'Не удалось загрузить профиль школьника: ' + (error.message || 'Неизвестная ошибка'),
|
|
||||||
indicator: 'red',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -792,7 +772,7 @@ const form = ref({
|
|||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
const username = effectiveUsername.value;
|
const username = effectiveUsername.value;
|
||||||
const crumbs = [
|
return [
|
||||||
{
|
{
|
||||||
label: 'People',
|
label: 'People',
|
||||||
route: { name: 'People' },
|
route: { name: 'People' },
|
||||||
@@ -805,35 +785,20 @@ const breadcrumbs = computed(() => {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
console.log('[DEBUG] Хлебные крошки:', crumbs);
|
|
||||||
return crumbs;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => ({
|
||||||
const meta = {
|
title: profile.data?.full_name || 'Профиль',
|
||||||
title: profile.data?.full_name || 'Профиль',
|
description: profile.data?.headline || '',
|
||||||
description: profile.data?.headline || '',
|
}));
|
||||||
};
|
|
||||||
console.log('[DEBUG] Мета-данные страницы:', meta);
|
|
||||||
return meta;
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
if (!profile.data) {
|
if (!profile.data) return 'Загрузка...';
|
||||||
console.log('[DEBUG] displayName: profile.data не загружен');
|
return profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
|
||||||
return 'Загрузка...';
|
|
||||||
}
|
|
||||||
const name = profile.data?.full_name || `${form.value.first_name || ''} ${form.value.last_name || ''}`.trim();
|
|
||||||
console.log('[DEBUG] Отображаемое имя:', name);
|
|
||||||
return name;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSessionUser = () => {
|
const isSessionUser = () => {
|
||||||
const sessionUser = $user.data?.username;
|
return $user.data?.username === effectiveUsername.value;
|
||||||
const profileUser = effectiveUsername.value;
|
|
||||||
const isSession = sessionUser === profileUser;
|
|
||||||
console.log('[DEBUG] Проверка isSessionUser:', { sessionUser, profileUser, isSession });
|
|
||||||
return isSession;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formattedDate(d) {
|
function formattedDate(d) {
|
||||||
@@ -841,7 +806,6 @@ function formattedDate(d) {
|
|||||||
try {
|
try {
|
||||||
return new Date(d).toLocaleDateString('ru-RU');
|
return new Date(d).toLocaleDateString('ru-RU');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[DEBUG] Ошибка форматирования даты:', e, { date: d });
|
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -862,11 +826,6 @@ function formatTelegram(t) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fillFormFromProfile() {
|
function fillFormFromProfile() {
|
||||||
console.log('[DEBUG] Заполнение формы:', {
|
|
||||||
schoolProfile: schoolProfile.data,
|
|
||||||
profile: profile.data,
|
|
||||||
currentForm: JSON.stringify(form.value, null, 2),
|
|
||||||
});
|
|
||||||
form.value.first_name = schoolProfile.data?.first_name || profile.data?.first_name || '';
|
form.value.first_name = schoolProfile.data?.first_name || profile.data?.first_name || '';
|
||||||
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
|
form.value.last_name = schoolProfile.data?.last_name || profile.data?.last_name || '';
|
||||||
form.value.middle_name = schoolProfile.data?.middle_name || '';
|
form.value.middle_name = schoolProfile.data?.middle_name || '';
|
||||||
@@ -883,38 +842,20 @@ function fillFormFromProfile() {
|
|||||||
form.value.interests = schoolProfile.data?.interests || '';
|
form.value.interests = schoolProfile.data?.interests || '';
|
||||||
form.value.about_me = schoolProfile.data?.about_me || '';
|
form.value.about_me = schoolProfile.data?.about_me || '';
|
||||||
form.value.dreams = schoolProfile.data?.dreams || '';
|
form.value.dreams = schoolProfile.data?.dreams || '';
|
||||||
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function toggleEdit() {
|
function toggleEdit() {
|
||||||
editMode.value = !editMode.value;
|
editMode.value = !editMode.value;
|
||||||
if (editMode.value) fillFormFromProfile();
|
if (editMode.value) fillFormFromProfile();
|
||||||
console.log('[DEBUG] Переключение режима редактирования:', { editMode: editMode.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateExams(exams) {
|
|
||||||
console.log('[DEBUG] Валидация exams:', { exams, validOptions: examOptions });
|
|
||||||
return exams.every(exam => examOptions.includes(exam));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateLearnSubjects(subjects) {
|
|
||||||
console.log('[DEBUG] Валидация learn_subjects:', { subjects, validOptions: learnOptions });
|
|
||||||
return subjects.every(subject => learnOptions.includes(subject));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
console.log('[DEBUG] Сохранение профиля:', { form: form.value });
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
// Создаём копию данных формы
|
|
||||||
const formData = { ...form.value };
|
const formData = { ...form.value };
|
||||||
console.log('[DEBUG] Копия formData:', JSON.stringify(formData, null, 2));
|
|
||||||
|
|
||||||
// Обновление full_name в User, если нужно
|
|
||||||
if (formData.first_name || formData.last_name) {
|
if (formData.first_name || formData.last_name) {
|
||||||
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
|
const fullName = `${formData.first_name || ''} ${formData.last_name || ''}`.trim();
|
||||||
console.log('[DEBUG] Обновление User.full_name:', { name: profile.data?.name, fullName });
|
|
||||||
await createResource({
|
await createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
params: {
|
params: {
|
||||||
@@ -926,18 +867,14 @@ async function saveProfile() {
|
|||||||
}).submit();
|
}).submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем docname
|
|
||||||
let docname = '';
|
let docname = '';
|
||||||
try {
|
try {
|
||||||
await schoolProfile.reload();
|
await schoolProfile.reload();
|
||||||
console.log('[DEBUG] Schoolprofile:', { schoolProfile });
|
|
||||||
docname = schoolProfile?.data?.name;
|
docname = schoolProfile?.data?.name;
|
||||||
console.log('[DEBUG] Выбранное имя документа:', docname);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[DEBUG] Ошибка загрузки schoolProfile, продолжаем с profile:', error.message);
|
// профиль не найден, создаём новый
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формируем payload из копии данных формы
|
|
||||||
let payload = {
|
let payload = {
|
||||||
doctype: 'Schoolchildren Profile',
|
doctype: 'Schoolchildren Profile',
|
||||||
user: profile.data?.name,
|
user: profile.data?.name,
|
||||||
@@ -959,9 +896,7 @@ async function saveProfile() {
|
|||||||
dreams: formData.dreams,
|
dreams: formData.dreams,
|
||||||
last_updated: new Date().toISOString(),
|
last_updated: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
console.log('[DEBUG] Сохранение Schoolchildren Profile (payload):', { docname, payload });
|
|
||||||
|
|
||||||
// Сохранение или создание документа
|
|
||||||
if (docname) {
|
if (docname) {
|
||||||
await createResource({
|
await createResource({
|
||||||
url: 'frappe.client.save',
|
url: 'frappe.client.save',
|
||||||
@@ -975,11 +910,9 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editMode.value = false;
|
editMode.value = false;
|
||||||
schoolProfileNotFound.value = false;
|
schoolProfileNotFound.value = false;
|
||||||
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
|
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint('Профиль сохранён');
|
||||||
console.log('[DEBUG] Профиль успешно сохранён');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[DEBUG] Ошибка при сохранении профиля:', e);
|
|
||||||
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
|
if (window.frappe && window.frappe.msgprint) window.frappe.msgprint({
|
||||||
title: 'Ошибка',
|
title: 'Ошибка',
|
||||||
message: (e && e.message) || 'Ошибка при сохранении',
|
message: (e && e.message) || 'Ошибка при сохранении',
|
||||||
@@ -1000,7 +933,6 @@ async function searchSchool(q) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.log('[DEBUG] Поиск школы:', { query: q });
|
|
||||||
const res = await createResource({
|
const res = await createResource({
|
||||||
url: 'frappe.client.get_list',
|
url: 'frappe.client.get_list',
|
||||||
params: {
|
params: {
|
||||||
@@ -1011,10 +943,8 @@ async function searchSchool(q) {
|
|||||||
},
|
},
|
||||||
}).submit();
|
}).submit();
|
||||||
schoolResults.value = res || [];
|
schoolResults.value = res || [];
|
||||||
console.log('[DEBUG] Результаты поиска школы:', schoolResults.value);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
schoolResults.value = [];
|
schoolResults.value = [];
|
||||||
console.error('[DEBUG] Ошибка поиска школы:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,11 +952,8 @@ const debouncedSearchSchool = debounce(() => searchSchool(schoolQuery.value), 30
|
|||||||
|
|
||||||
function selectSchool(s) {
|
function selectSchool(s) {
|
||||||
form.value.school = s.school;
|
form.value.school = s.school;
|
||||||
//form.value.school_name = s.school_name;
|
|
||||||
schoolResults.value = [];
|
schoolResults.value = [];
|
||||||
schoolQuery.value = s.school;
|
schoolQuery.value = s.school;
|
||||||
console.log('[DEBUG] Выбрана школа:', { school: s });
|
|
||||||
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const majorQuery = ref('');
|
const majorQuery = ref('');
|
||||||
@@ -1038,7 +965,6 @@ async function searchMajor(q) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.log('[DEBUG] Поиск направления:', { query: q });
|
|
||||||
const res = await createResource({
|
const res = await createResource({
|
||||||
url: 'frappe.client.get_list',
|
url: 'frappe.client.get_list',
|
||||||
params: {
|
params: {
|
||||||
@@ -1049,10 +975,8 @@ async function searchMajor(q) {
|
|||||||
},
|
},
|
||||||
}).submit();
|
}).submit();
|
||||||
majorResults.value = res || [];
|
majorResults.value = res || [];
|
||||||
console.log('[DEBUG] Результаты поиска направления:', majorResults.value);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
majorResults.value = [];
|
majorResults.value = [];
|
||||||
console.error('[DEBUG] Ошибка поиска направления:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,40 +984,27 @@ const debouncedSearchMajor = debounce(() => searchMajor(majorQuery.value), 300);
|
|||||||
|
|
||||||
function selectMajor(m) {
|
function selectMajor(m) {
|
||||||
form.value.major = m.major_name;
|
form.value.major = m.major_name;
|
||||||
//form.value.school_name = s.school_name;
|
|
||||||
majorResults.value = [];
|
majorResults.value = [];
|
||||||
majorQuery.value = m.major_name;
|
majorQuery.value = m.major_name;
|
||||||
console.log('[DEBUG] Выбрана школа:', { major: m });
|
|
||||||
console.log('[DEBUG] Форма после заполнения:', JSON.stringify(form.value, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log('[DEBUG] Компонент смонтирован:', {
|
|
||||||
propsUsername: props.username,
|
|
||||||
sessionUsername: $user.data?.username,
|
|
||||||
user: user,
|
|
||||||
$user: $user.data,
|
|
||||||
});
|
|
||||||
if ($user.data) {
|
if ($user.data) {
|
||||||
console.log('[DEBUG] Запуск profile.reload()');
|
|
||||||
profile.reload();
|
profile.reload();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.username,
|
() => props.username,
|
||||||
(newUsername, oldUsername) => {
|
() => {
|
||||||
console.log('[DEBUG] Изменение props.username:', { old: oldUsername, new: newUsername });
|
|
||||||
profile.reload();
|
profile.reload();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => profile.data,
|
() => profile.data,
|
||||||
(newData, oldData) => {
|
(newData) => {
|
||||||
console.log('[DEBUG] Изменение profile.data:', { old: oldData, new: newData });
|
|
||||||
if (newData) {
|
if (newData) {
|
||||||
console.log('[DEBUG] Запуск schoolProfile.reload()');
|
|
||||||
schoolProfile.reload();
|
schoolProfile.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1101,10 +1012,8 @@ watch(
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => schoolProfile.data,
|
() => schoolProfile.data,
|
||||||
(newData, oldData) => {
|
(newData) => {
|
||||||
console.log('[DEBUG] Изменение schoolProfile.data:', { old: oldData, new: newData });
|
|
||||||
if (newData && !editMode.value && !schoolProfileNotFound.value) {
|
if (newData && !editMode.value && !schoolProfileNotFound.value) {
|
||||||
console.log('[DEBUG] Заполнение формы из schoolProfile');
|
|
||||||
fillFormFromProfile();
|
fillFormFromProfile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1113,7 +1022,6 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => effectiveUsername.value,
|
() => effectiveUsername.value,
|
||||||
(newUsername) => {
|
(newUsername) => {
|
||||||
console.log('[DEBUG] Изменение effectiveUsername для schoolProfile:', newUsername);
|
|
||||||
schoolProfile.update({
|
schoolProfile.update({
|
||||||
params: {
|
params: {
|
||||||
doctype: 'Schoolchildren Profile',
|
doctype: 'Schoolchildren Profile',
|
||||||
|
|||||||
@@ -271,6 +271,17 @@ const routes = [
|
|||||||
name: 'Olympiads',
|
name: 'Olympiads',
|
||||||
component: () => import('@/pages/Olympiads/Olympiads.vue'),
|
component: () => import('@/pages/Olympiads/Olympiads.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/olympiads/new',
|
||||||
|
name: 'OlympiadNew',
|
||||||
|
component: () => import('@/pages/Olympiads/OlympiadForm.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/olympiads/:olympiadName/edit',
|
||||||
|
name: 'OlympiadEdit',
|
||||||
|
component: () => import('@/pages/Olympiads/OlympiadForm.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/olympiads/:olympiadName',
|
path: '/olympiads/:olympiadName',
|
||||||
name: 'OlympiadDetail',
|
name: 'OlympiadDetail',
|
||||||
|
|||||||
@@ -541,6 +541,15 @@ const getSidebarItems = () => {
|
|||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
hideLabel: true,
|
hideLabel: true,
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Create Olympiad',
|
||||||
|
icon: 'PlusCircle',
|
||||||
|
to: 'OlympiadNew',
|
||||||
|
condition: () => {
|
||||||
|
return isAdmin()
|
||||||
|
},
|
||||||
|
activeFor: ['OlympiadNew', 'OlympiadEdit'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Quizzes',
|
label: 'Quizzes',
|
||||||
icon: 'CircleHelp',
|
icon: 'CircleHelp',
|
||||||
|
|||||||
@@ -8320,3 +8320,99 @@ msgstr "Чем вы занимаетесь на платформе?"
|
|||||||
|
|
||||||
msgid "Установить приложение IIE"
|
msgid "Установить приложение IIE"
|
||||||
msgstr "Установить приложение IIE"
|
msgstr "Установить приложение IIE"
|
||||||
|
|
||||||
|
msgid "Olympiads"
|
||||||
|
msgstr "Олимпиады"
|
||||||
|
|
||||||
|
msgid "Academic Olympiads"
|
||||||
|
msgstr "Академические олимпиады"
|
||||||
|
|
||||||
|
msgid "Olympiad"
|
||||||
|
msgstr "Олимпиада"
|
||||||
|
|
||||||
|
msgid "Registration Open"
|
||||||
|
msgstr "Регистрация открыта"
|
||||||
|
|
||||||
|
msgid "In Progress"
|
||||||
|
msgstr "Идёт"
|
||||||
|
|
||||||
|
msgid "Completed"
|
||||||
|
msgstr "Завершена"
|
||||||
|
|
||||||
|
msgid "Register"
|
||||||
|
msgstr "Зарегистрироваться"
|
||||||
|
|
||||||
|
msgid "Unregister"
|
||||||
|
msgstr "Отменить регистрацию"
|
||||||
|
|
||||||
|
msgid "Leaderboard"
|
||||||
|
msgstr "Таблица лидеров"
|
||||||
|
|
||||||
|
msgid "Participants"
|
||||||
|
msgstr "Участники"
|
||||||
|
|
||||||
|
msgid "Subject"
|
||||||
|
msgstr "Предмет"
|
||||||
|
|
||||||
|
msgid "Create Olympiad"
|
||||||
|
msgstr "Создать олимпиаду"
|
||||||
|
|
||||||
|
msgid "New Olympiad"
|
||||||
|
msgstr "Новая олимпиада"
|
||||||
|
|
||||||
|
msgid "Edit Olympiad"
|
||||||
|
msgstr "Редактировать олимпиаду"
|
||||||
|
|
||||||
|
msgid "olympiads"
|
||||||
|
msgstr "олимпиад"
|
||||||
|
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr "Черновик"
|
||||||
|
|
||||||
|
msgid "Fill in the olympiad details below"
|
||||||
|
msgstr "Заполните данные олимпиады ниже"
|
||||||
|
|
||||||
|
msgid "Olympiad title"
|
||||||
|
msgstr "Название олимпиады"
|
||||||
|
|
||||||
|
msgid "Describe the olympiad..."
|
||||||
|
msgstr "Опишите олимпиаду..."
|
||||||
|
|
||||||
|
msgid "Registration Start"
|
||||||
|
msgstr "Начало регистрации"
|
||||||
|
|
||||||
|
msgid "Registration End"
|
||||||
|
msgstr "Конец регистрации"
|
||||||
|
|
||||||
|
msgid "Olympiad Start"
|
||||||
|
msgstr "Начало олимпиады"
|
||||||
|
|
||||||
|
msgid "Olympiad End"
|
||||||
|
msgstr "Конец олимпиады"
|
||||||
|
|
||||||
|
msgid "Max Participants"
|
||||||
|
msgstr "Максимум участников"
|
||||||
|
|
||||||
|
msgid "Leave empty for unlimited"
|
||||||
|
msgstr "Оставьте пустым для неограниченного количества"
|
||||||
|
|
||||||
|
msgid "Passing Score"
|
||||||
|
msgstr "Проходной балл"
|
||||||
|
|
||||||
|
msgid "Save Changes"
|
||||||
|
msgstr "Сохранить изменения"
|
||||||
|
|
||||||
|
msgid "Title is required"
|
||||||
|
msgstr "Название обязательно"
|
||||||
|
|
||||||
|
msgid "Olympiad updated"
|
||||||
|
msgstr "Олимпиада обновлена"
|
||||||
|
|
||||||
|
msgid "Olympiad created"
|
||||||
|
msgstr "Олимпиада создана"
|
||||||
|
|
||||||
|
msgid "Failed to load olympiad: "
|
||||||
|
msgstr "Не удалось загрузить олимпиаду: "
|
||||||
|
|
||||||
|
msgid "Error saving olympiad: "
|
||||||
|
msgstr "Ошибка сохранения олимпиады: "
|
||||||
|
|||||||
Reference in New Issue
Block a user