changes
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
class PostStatus {
|
||||
static DRAFT = { value: 'draft', label: 'Черновик', color: 'gray', name: 'DRAFT', type_label: 'Статус публикации' };
|
||||
static PUBLISHED = { value: 'published', label: 'Опубликовано', color: 'success', name: 'PUBLISHED', type_label: 'Статус публикации' };
|
||||
static VERIFICATION = { value: 'verification', label: 'На проверке', color: 'warning', name: 'VERIFICATION', type_label: 'Статус публикации' };
|
||||
static REJECTED = { value: 'rejected', label: 'Отклонено', color: 'danger', name: 'REJECTED', type_label: 'Статус публикации' };
|
||||
|
||||
// Метод для получения статуса по имени
|
||||
static fromName(name) {
|
||||
return this[name] || null;
|
||||
}
|
||||
|
||||
static fromValue(value) {
|
||||
return Object.values(this).find(item => item.value == value) || null;
|
||||
}
|
||||
|
||||
static getLabel(value) {
|
||||
const status = this.fromValue(value);
|
||||
return status ? status.label : value;
|
||||
}
|
||||
|
||||
static getColor(value) {
|
||||
const status = this.fromValue(value);
|
||||
return status ? status.color : 'gray';
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
export default PostStatus;
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center px-4">
|
||||
<div class="max-w-md w-full">
|
||||
<!-- Logo & Title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-2xl mb-4">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">Панель управления</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">NTSPI Administration System</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="bg-white/5 backdrop-blur-lg border border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-300 mb-2">
|
||||
Email адрес
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<p v-if="form.errors.email" class="mt-2 text-sm text-rose-400">{{ form.errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-300 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p v-if="form.errors.password" class="mt-2 text-sm text-rose-400">{{ form.errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="remember"
|
||||
v-model="form.remember"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-white/20 bg-white/10 text-primary focus:ring-primary/50 focus:ring-offset-0"
|
||||
/>
|
||||
<label for="remember" class="ml-2 block text-sm text-slate-300">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Status Message -->
|
||||
<div v-if="status" class="p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
|
||||
<p class="text-sm text-emerald-300">{{ status }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="w-full bg-primary hover:bg-primary/90 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span v-if="form.processing" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Вход...
|
||||
</span>
|
||||
<span v-else>Войти в систему</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Back to site link -->
|
||||
<div class="text-center mt-6">
|
||||
<a href="/" class="text-sm text-slate-400 hover:text-white transition-colors inline-flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Вернуться на сайт
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
|
||||
defineOptions({
|
||||
name: 'Login',
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
canResetPassword: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('login'), {
|
||||
onFinish: () => form.reset('password'),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Создание научного журнала</template>
|
||||
<template #header-subtitle>Добавление нового научного журнала</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к списку
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Основные данные -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Основные данные</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название журнала <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Введите полное название журнала"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
@input="generateSlug"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Официальное название журнала как в регистрационных документах</p>
|
||||
<p v-if="errors.title" class="mt-1 text-sm text-danger">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
URL-адрес <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center px-3 py-2 border border-r-0 border-layer-line rounded-l-lg bg-muted text-muted-foreground-1 text-sm">
|
||||
{{ baseUrl }}/
|
||||
</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
required
|
||||
readonly
|
||||
class="flex-1 px-3 py-2 border border-layer-line rounded-r-lg bg-muted text-muted-foreground-1 text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Формируется автоматически из названия</p>
|
||||
<p v-if="errors.slug" class="mt-1 text-sm text-danger">{{ errors.slug }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основная информация (Content Builder) -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Описание журнала</h3>
|
||||
<ContentBuilder
|
||||
v-model="form.main_info"
|
||||
label="Описание журнала"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">Добавьте полное описание журнала, его историю и основные направления</p>
|
||||
</div>
|
||||
|
||||
<!-- Редакционная коллегия -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Редакционная коллегия</h3>
|
||||
|
||||
<!-- Главный редактор -->
|
||||
<div class="border border-layer-line rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-foreground mb-3">Главный редактор</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
ФИО <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].name"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учёная степень <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].academicTitle"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="д.т.н., профессор"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Должность <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].position"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="Главный научный сотрудник"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учреждение <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].institution"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="МГУ имени М.В. Ломоносова"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Редакционная коллегия -->
|
||||
<div class="border border-layer-line rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-medium text-foreground">Члены редакционной коллегии</h4>
|
||||
<button
|
||||
type="button"
|
||||
@click="addEditor"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить редактора
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(editor, index) in form.editors"
|
||||
:key="index"
|
||||
class="bg-muted/30 border border-layer-line rounded-lg p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-foreground">Редактор #{{ index + 1 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeEditor(index)"
|
||||
class="text-muted-foreground-1 hover:text-danger transition-colors"
|
||||
>
|
||||
<DashboardIcon name="x-mark" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
ФИО <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Петров Петр Петрович"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учёная степень <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.academicTitle"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="к.ф.-м.н., доцент"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Должность <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.position"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Доцент кафедры"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учреждение <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.institution"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="СПбГУ"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="form.editors.length === 0" class="text-xs text-muted-foreground-1 text-center py-4">
|
||||
Добавьте членов редакционной коллегии журнала
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация для авторов (Content Builder) -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Информация для авторов</h3>
|
||||
<ContentBuilder
|
||||
v-model="form.for_authors"
|
||||
label="Требования к статьям"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">Разместите требования к статьям, правила оформления и сроки подачи</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.index')"
|
||||
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ processing ? 'Создание...' : 'Создать журнал' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import ContentBuilder from '../Components/ContentBuilder/ContentBuilder.vue';
|
||||
|
||||
export default {
|
||||
name: 'AcademicJournalCreate',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
ContentBuilder,
|
||||
},
|
||||
|
||||
props: {
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
slug: '',
|
||||
main_info: [],
|
||||
chief_editor: [{ name: '', academicTitle: '', position: '', institution: '' }],
|
||||
editors: [],
|
||||
for_authors: []
|
||||
},
|
||||
processing: false,
|
||||
baseUrl: this.GET_BASE_URL() + 'academic-journals'
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание научного журнала');
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (this.form.title) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
addEditor() {
|
||||
this.form.editors.push({
|
||||
name: '',
|
||||
academicTitle: '',
|
||||
position: '',
|
||||
institution: ''
|
||||
});
|
||||
},
|
||||
|
||||
removeEditor(index) {
|
||||
this.form.editors.splice(index, 1);
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.$inertia.post(route('dashboard.academic-journals.store'), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Редактирование научного журнала</template>
|
||||
<template #header-subtitle>Изменение данных журнала "{{ journal.title }}"</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к списку
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Основные данные -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Основные данные</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название журнала <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Введите полное название журнала"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
@input="generateSlug"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Официальное название журнала как в регистрационных документах</p>
|
||||
<p v-if="errors.title" class="mt-1 text-sm text-danger">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
URL-адрес <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center px-3 py-2 border border-r-0 border-layer-line rounded-l-lg bg-muted text-muted-foreground-1 text-sm">
|
||||
{{ baseUrl }}/
|
||||
</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
required
|
||||
readonly
|
||||
class="flex-1 px-3 py-2 border border-layer-line rounded-r-lg bg-muted text-muted-foreground-1 text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Формируется автоматически из названия</p>
|
||||
<p v-if="errors.slug" class="mt-1 text-sm text-danger">{{ errors.slug }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основная информация (Content Builder) -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Описание журнала</h3>
|
||||
<ContentBuilder
|
||||
v-model="form.main_info"
|
||||
label="Описание журнала"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">Добавьте полное описание журнала, его историю и основные направления</p>
|
||||
</div>
|
||||
|
||||
<!-- Редакционная коллегия -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Редакционная коллегия</h3>
|
||||
|
||||
<!-- Главный редактор -->
|
||||
<div class="border border-layer-line rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-foreground mb-3">Главный редактор</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
ФИО <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].name"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учёная степень <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].academicTitle"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="д.т.н., профессор"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Должность <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].position"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="Главный научный сотрудник"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учреждение <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.chief_editor[0].institution"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
placeholder="МГУ имени М.В. Ломоносова"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Редакционная коллегия -->
|
||||
<div class="border border-layer-line rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-medium text-foreground">Члены редакционной коллегии</h4>
|
||||
<button
|
||||
type="button"
|
||||
@click="addEditor"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить редактора
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(editor, index) in form.editors"
|
||||
:key="index"
|
||||
class="bg-muted/30 border border-layer-line rounded-lg p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-foreground">Редактор #{{ index + 1 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeEditor(index)"
|
||||
class="text-muted-foreground-1 hover:text-danger transition-colors"
|
||||
>
|
||||
<DashboardIcon name="x-mark" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
ФИО <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Петров Петр Петрович"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учёная степень <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.academicTitle"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="к.ф.-м.н., доцент"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Должность <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.position"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Доцент кафедры"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-foreground mb-1">
|
||||
Учреждение <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editor.institution"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="СПбГУ"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="form.editors.length === 0" class="text-xs text-muted-foreground-1 text-center py-4">
|
||||
Добавьте членов редакционной коллегии журнала
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация для авторов (Content Builder) -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Информация для авторов</h3>
|
||||
<ContentBuilder
|
||||
v-model="form.for_authors"
|
||||
label="Требования к статьям"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">Разместите требования к статьям, правила оформления и сроки подачи</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.index')"
|
||||
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import ContentBuilder from '../Components/ContentBuilder/ContentBuilder.vue';
|
||||
|
||||
export default {
|
||||
name: 'AcademicJournalEdit',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
ContentBuilder,
|
||||
},
|
||||
|
||||
props: {
|
||||
journal: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: this.journal.title || '',
|
||||
slug: this.journal.slug || '',
|
||||
main_info: this.journal.main_info || [],
|
||||
chief_editor: (this.journal.chief_editor && this.journal.chief_editor.length > 0)
|
||||
? this.journal.chief_editor
|
||||
: [{ name: '', academicTitle: '', position: '', institution: '' }],
|
||||
editors: this.journal.editors || [],
|
||||
for_authors: this.journal.for_authors || []
|
||||
},
|
||||
processing: false,
|
||||
baseUrl: this.GET_BASE_URL() + 'academic-journals'
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Редактирование научного журнала');
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (this.form.title) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
addEditor() {
|
||||
this.form.editors.push({
|
||||
name: '',
|
||||
academicTitle: '',
|
||||
position: '',
|
||||
institution: ''
|
||||
});
|
||||
},
|
||||
|
||||
removeEditor(index) {
|
||||
this.form.editors.splice(index, 1);
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.$inertia.put(route('dashboard.academic-journals.update', this.journal.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="beaker" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Научные журналы</template>
|
||||
<template #header-subtitle>Управление научными журналами</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Создать журнал
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Filters Card -->
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск по названию"
|
||||
placeholder="Введите название журнала..."
|
||||
@search="search"
|
||||
/>
|
||||
</DataFilters>
|
||||
|
||||
<!-- Journals Table -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Table Header Stats -->
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ journals.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ journals.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название журнала
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
URL
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Дата создания
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="journal in journals.data"
|
||||
:key="journal.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ journal.title }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-muted-foreground-1 font-mono">
|
||||
{{ journal.slug }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-foreground">{{ FORMAT_DATE(journal.created_at, 'full') }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.edit', journal.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.index', journal.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-blue-600 hover:bg-blue-500/10 rounded-lg transition-all"
|
||||
title="Выпуски журнала"
|
||||
>
|
||||
<DashboardIcon name="document-text" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="CONFIRM_AND_DELETE(journal, 'dashboard.academic-journals.destroy', {
|
||||
message: 'Удалить журнал «' + journal.title + '»?'
|
||||
})"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-if="journals.data.length === 0"
|
||||
:columns="4"
|
||||
title="Научные журналы не найдены"
|
||||
description="Создайте первый научный журнал или измените параметры поиска"
|
||||
:action-url="route('dashboard.academic-journals.create')"
|
||||
action-text="Создать журнал"
|
||||
icon-path="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :data="journals" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../Components/shared/SearchInput.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'AcademicJournalIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
journals: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
search: ''
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Научные журналы');
|
||||
},
|
||||
|
||||
methods: {
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.academic-journals.index', {
|
||||
search: this.searchQuery
|
||||
});
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery'],
|
||||
'dashboard.academic-journals.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.$inertia.get(route('dashboard.academic-journals.index'), {
|
||||
search: this.searchQuery
|
||||
}, {
|
||||
preserveState: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Создание выпуска журнала</template>
|
||||
<template #header-subtitle>{{ journal.title }}</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.index', journal.id)"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к списку
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Основные поля -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Основная информация</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название выпуска <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder='Например: "Том 15, №3 (2023)"'
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Например: "Том 15, №3 (2023)" или специальное название выпуска</p>
|
||||
<p v-if="errors.title" class="mt-1 text-sm text-danger">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Год публикации <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.year_publication"
|
||||
type="number"
|
||||
required
|
||||
:min="1900"
|
||||
:max="maxYear"
|
||||
placeholder="Укажите год выпуска"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Год должен быть в диапазоне от 1900 до {{ maxYear }}</p>
|
||||
<p v-if="errors.year_publication" class="mt-1 text-sm text-danger">{{ errors.year_publication }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Файл выпуска <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
required
|
||||
@change="handleFileChange"
|
||||
accept=".pdf,.docx,.xlsx,.pptx,.zip"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Максимальный размер файла: 256MB. Допустимые форматы: PDF, DOCX, XLSX, PPTX, ZIP
|
||||
</p>
|
||||
<p v-if="errors.path_file" class="mt-1 text-sm text-danger">{{ errors.path_file }}</p>
|
||||
<p v-if="fileError" class="mt-1 text-sm text-danger">{{ fileError }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Статус
|
||||
</label>
|
||||
<div class="flex items-center mt-2">
|
||||
<input
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="ml-2 text-sm text-foreground">
|
||||
Активный выпуск
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Активные выпуски отображаются на сайте</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.index', journal.id)"
|
||||
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ processing ? 'Создание...' : 'Создать выпуск' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'JournalIssueCreate',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
},
|
||||
|
||||
props: {
|
||||
journal: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
year_publication: new Date().getFullYear(),
|
||||
path_file: null,
|
||||
is_active: true
|
||||
},
|
||||
fileError: null,
|
||||
processing: false,
|
||||
maxYear: new Date().getFullYear() + 1
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание выпуска журнала');
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files[0];
|
||||
this.fileError = null;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка размера (256MB)
|
||||
const maxSize = 256 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
this.fileError = 'Размер файла не должен превышать 256MB';
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка формата
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip'
|
||||
];
|
||||
|
||||
const allowedExtensions = ['.pdf', '.docx', '.xlsx', '.pptx', '.zip'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
|
||||
this.fileError = 'Неподдерживаемый формат файла. Допустимые: PDF, DOCX, XLSX, PPTX, ZIP';
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.path_file = file;
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
|
||||
// Создаем FormData для загрузки файла
|
||||
const formData = new FormData();
|
||||
formData.append('title', this.form.title);
|
||||
formData.append('year_publication', this.form.year_publication);
|
||||
formData.append('is_active', this.form.is_active ? 1 : 0);
|
||||
|
||||
if (this.form.path_file instanceof File) {
|
||||
formData.append('path_file', this.form.path_file);
|
||||
}
|
||||
|
||||
this.$inertia.post(
|
||||
route('dashboard.academic-journals.issues.store', this.journal.id),
|
||||
formData,
|
||||
{
|
||||
forceFormData: true,
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Редактирование выпуска журнала</template>
|
||||
<template #header-subtitle>{{ journal.title }} — {{ issue.title }}</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.index', journal.id)"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к списку
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Основные поля -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-foreground">Основная информация</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название выпуска <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder='Например: "Том 15, №3 (2023)"'
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Например: "Том 15, №3 (2023)" или специальное название выпуска</p>
|
||||
<p v-if="errors.title" class="mt-1 text-sm text-danger">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Год публикации <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.year_publication"
|
||||
type="number"
|
||||
required
|
||||
:min="1900"
|
||||
:max="maxYear"
|
||||
placeholder="Укажите год выпуска"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Год должен быть в диапазоне от 1900 до {{ maxYear }}</p>
|
||||
<p v-if="errors.year_publication" class="mt-1 text-sm text-danger">{{ errors.year_publication }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Файл выпуска <span class="text-danger">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Текущий файл -->
|
||||
<div v-if="form.existing_file" class="mb-2 p-3 bg-muted/30 border border-layer-line rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-muted-foreground-1" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ getFileName(form.existing_file) }}</p>
|
||||
<a
|
||||
:href="RESOLVE_ASSET_URL(form.existing_file)"
|
||||
target="_blank"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
Открыть файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@change="handleFileChange"
|
||||
accept=".pdf,.docx,.xlsx,.pptx,.zip"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Оставьте пустым, чтобы сохранить текущий файл. Максимальный размер: 256MB.
|
||||
</p>
|
||||
<p v-if="fileError" class="mt-1 text-sm text-danger">{{ fileError }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Статус
|
||||
</label>
|
||||
<div class="flex items-center mt-2">
|
||||
<input
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="ml-2 text-sm text-foreground">
|
||||
Активный выпуск
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Активные выпуски отображаются на сайте</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.index', journal.id)"
|
||||
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'JournalIssueEdit',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
},
|
||||
|
||||
props: {
|
||||
journal: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: this.issue.title || '',
|
||||
year_publication: this.issue.year_publication || new Date().getFullYear(),
|
||||
path_file: null,
|
||||
existing_file: this.issue.path_file || null,
|
||||
is_active: this.issue.is_active ?? true
|
||||
},
|
||||
fileError: null,
|
||||
processing: false,
|
||||
maxYear: new Date().getFullYear() + 1
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Редактирование выпуска журнала');
|
||||
},
|
||||
|
||||
methods: {
|
||||
getFileName(path) {
|
||||
if (!path) return '';
|
||||
return path.split('/').pop();
|
||||
},
|
||||
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files[0];
|
||||
this.fileError = null;
|
||||
|
||||
if (!file) {
|
||||
this.form.path_file = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка размера (256MB)
|
||||
const maxSize = 256 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
this.fileError = 'Размер файла не должен превышать 256MB';
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка формата
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip'
|
||||
];
|
||||
|
||||
const allowedExtensions = ['.pdf', '.docx', '.xlsx', '.pptx', '.zip'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
|
||||
this.fileError = 'Неподдерживаемый формат файла. Допустимые: PDF, DOCX, XLSX, PPTX, ZIP';
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.path_file = file;
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
|
||||
// Создаем FormData для загрузки файла
|
||||
const formData = new FormData();
|
||||
formData.append('title', this.form.title);
|
||||
formData.append('year_publication', this.form.year_publication);
|
||||
formData.append('is_active', this.form.is_active ? 1 : 0);
|
||||
formData.append('_method', 'PUT'); // Laravel method spoofing
|
||||
|
||||
if (this.form.path_file instanceof File) {
|
||||
formData.append('path_file', this.form.path_file);
|
||||
} else if (this.form.existing_file) {
|
||||
formData.append('path_file', this.form.existing_file);
|
||||
}
|
||||
|
||||
this.$inertia.post(
|
||||
route('dashboard.academic-journals.issues.update', {
|
||||
academicJournal: this.journal.id,
|
||||
issue: this.issue.id
|
||||
}),
|
||||
formData,
|
||||
{
|
||||
forceFormData: true,
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Выпуски журнала</template>
|
||||
<template #header-subtitle>{{ journal.title }}</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к журналам
|
||||
</a>
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.create', journal.id)"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Создать выпуск
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Filters Card -->
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск по названию"
|
||||
placeholder="Введите название выпуска..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="yearQuery"
|
||||
label="Год выпуска"
|
||||
placeholder="Все годы"
|
||||
@change="filterByYear"
|
||||
>
|
||||
<option v-for="year in years" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
|
||||
<SelectFilter
|
||||
v-model="activeQuery"
|
||||
label="Статус"
|
||||
placeholder="Все статусы"
|
||||
@change="filterByActive"
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="1">Активные</option>
|
||||
<option value="0">Неактивные</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<!-- Issues Table -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Table Header Stats -->
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ issues.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ issues.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название выпуска
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Год
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Дата добавления
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="issue in issues.data"
|
||||
:key="issue.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ issue.title }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground-1 mt-0.5">
|
||||
{{ issue.path_file }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-foreground">{{ issue.year_publication }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="STATUS_BADGE_CLASS(issue.is_active)">
|
||||
{{ issue.is_active ? 'Активный' : 'Неактивный' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-foreground">{{ FORMAT_DATE(issue.created_at, 'full') }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.academic-journals.issues.edit', { academicJournal: journal.id, issue: issue.id })"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<a
|
||||
:href="RESOLVE_ASSET_URL(issue.path_file)"
|
||||
target="_blank"
|
||||
class="p-2 text-muted-foreground-1 hover:text-blue-600 hover:bg-blue-500/10 rounded-lg transition-all"
|
||||
title="Открыть файл"
|
||||
>
|
||||
<DashboardIcon name="eye" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="CONFIRM_AND_DELETE(issue, 'dashboard.academic-journals.issues.destroy', {
|
||||
message: 'Удалить выпуск «' + issue.title + '»?'
|
||||
})"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-if="issues.data.length === 0"
|
||||
:columns="5"
|
||||
title="Выпуски не найдены"
|
||||
description="Создайте первый выпуск журнала или измените параметры поиска"
|
||||
:action-url="route('dashboard.academic-journals.issues.create', journal.id)"
|
||||
action-text="Создать выпуск"
|
||||
icon-path="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :data="issues" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'JournalIssueIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
journal: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
issues: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
search: '',
|
||||
year_publication: '',
|
||||
is_active: ''
|
||||
})
|
||||
},
|
||||
years: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
yearQuery: this.filters?.year_publication || '',
|
||||
activeQuery: this.filters?.is_active || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Выпуски журнала - ' + this.journal.title);
|
||||
},
|
||||
|
||||
methods: {
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.academic-journals.issues.index', {
|
||||
academicJournal: this.journal.id,
|
||||
search: this.searchQuery,
|
||||
year_publication: this.yearQuery,
|
||||
is_active: this.activeQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByYear() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
filterByActive() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'yearQuery', 'activeQuery'],
|
||||
'dashboard.academic-journals.issues.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.$inertia.get(route('dashboard.academic-journals.issues.index', this.journal.id), {
|
||||
search: this.searchQuery,
|
||||
year_publication: this.yearQuery,
|
||||
is_active: this.activeQuery
|
||||
}, {
|
||||
preserveState: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Flash Messages -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="$page.props.flash?.error" class="mb-4 p-4 bg-rose-500/10 border border-rose-500/20 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<DashboardIcon name="x-circle" size="5" class="text-rose-600 flex-shrink-0 mt-0.5" />
|
||||
<span class="text-sm text-foreground font-medium">{{ $page.props.flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-line-2">
|
||||
<nav class="flex -mb-px px-6" aria-label="Tabs">
|
||||
<button
|
||||
type="button"
|
||||
@click="activeTab = 'main'"
|
||||
:class="[
|
||||
activeTab === 'main'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2',
|
||||
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="information-circle" size="4" />
|
||||
Основные данные
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="activeTab = 'content'"
|
||||
:class="[
|
||||
activeTab === 'content'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2',
|
||||
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="4" />
|
||||
Содержание программы
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<!-- Main Info Tab -->
|
||||
<div v-show="activeTab === 'main'" class="space-y-6">
|
||||
<!-- General Info Section -->
|
||||
<div>
|
||||
<h3 class="text-base font-medium text-foreground mb-4">Общая информация</h3>
|
||||
<p class="text-xs text-muted-foreground-1 mb-4">Основные сведения о программе</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название программы <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@blur="generateSlug"
|
||||
placeholder='Например: "Цифровые технологии в управлении"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Полное официальное название программы</p>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-адрес <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
readonly
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-muted/30 border rounded-lg text-sm text-foreground cursor-not-allowed',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Человеко-понятный URL для страницы программы</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="mt-6">
|
||||
<label for="category_id" class="block text-sm font-medium text-foreground mb-2">
|
||||
Категория <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="category_id"
|
||||
v-model="form.category_id"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.category_id ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.category_id" class="mt-1.5 text-xs text-rose-500">{{ errors.category_id }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">К какой категории относится программа</p>
|
||||
</div>
|
||||
|
||||
<!-- Target Group -->
|
||||
<div class="mt-6">
|
||||
<label for="target_group" class="block text-sm font-medium text-foreground mb-2">
|
||||
Целевая аудитория <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="target_group"
|
||||
v-model="form.target_group"
|
||||
type="text"
|
||||
placeholder='Например: "Руководители среднего звена"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.target_group ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.target_group" class="mt-1.5 text-xs text-rose-500">{{ errors.target_group }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Для кого предназначена эта программа</p>
|
||||
</div>
|
||||
|
||||
<!-- Qualification -->
|
||||
<div class="mt-6">
|
||||
<label for="qualification" class="block text-sm font-medium text-foreground mb-2">
|
||||
Выдаваемый документ
|
||||
</label>
|
||||
<input
|
||||
id="qualification"
|
||||
v-model="form.qualification"
|
||||
type="text"
|
||||
placeholder='Например: "Удостоверение о повышении квалификации"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.qualification ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.qualification" class="mt-1.5 text-xs text-rose-500">{{ errors.qualification }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Какой документ получат слушатели</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Learning Parameters Section -->
|
||||
<div class="pt-6 border-t border-line-2">
|
||||
<h3 class="text-base font-medium text-foreground mb-4">Параметры обучения</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Price -->
|
||||
<div>
|
||||
<label for="price" class="block text-sm font-medium text-foreground mb-2">
|
||||
Стоимость (руб) <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="price"
|
||||
v-model="form.price"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Укажите стоимость"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.price ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.price" class="mt-1.5 text-xs text-rose-500">{{ errors.price }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Полная стоимость программы</p>
|
||||
</div>
|
||||
|
||||
<!-- Learning Time -->
|
||||
<div>
|
||||
<label for="learning_time" class="block text-sm font-medium text-foreground mb-2">
|
||||
Объем (часов) <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="learning_time"
|
||||
v-model="form.learning_time"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Укажите количество часов"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.learning_time ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.learning_time" class="mt-1.5 text-xs text-rose-500">{{ errors.learning_time }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Общий объем программы в академических часах</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Education -->
|
||||
<div class="mt-6">
|
||||
<label for="form_education" class="block text-sm font-medium text-foreground mb-2">
|
||||
Форма обучения <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="form_education"
|
||||
v-model="form.form_education"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.form_education ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите форму</option>
|
||||
<option v-for="formEducation in educationForms" :key="formEducation.value" :value="formEducation.value">
|
||||
{{ formEducation.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.form_education" class="mt-1.5 text-xs text-rose-500">{{ errors.form_education }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Основная форма проведения занятий</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активна для записи
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="is_active"
|
||||
@click="form.is_active = !form.is_active"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
form.is_active ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
form.is_active ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground-1">Отображать ли программу на сайте</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Tab -->
|
||||
<div v-show="activeTab === 'content'">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-base font-medium text-foreground mb-2">Описание программы</h3>
|
||||
<p class="text-xs text-muted-foreground-1">Создайте подробное описание программы с помощью конструктора</p>
|
||||
</div>
|
||||
|
||||
<ContentBuilder
|
||||
v-model="form.content"
|
||||
label="Содержание программы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import ContentBuilder from '../Components/ContentBuilder/ContentBuilder.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdditionalEducationForm',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
ContentBuilder
|
||||
},
|
||||
|
||||
props: {
|
||||
education: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
educationForms: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: 'Создать программу'
|
||||
},
|
||||
submitRoute: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
submitMethod: {
|
||||
type: String,
|
||||
default: 'post'
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'main',
|
||||
form: {
|
||||
title: this.education?.title || '',
|
||||
slug: this.education?.slug || '',
|
||||
category_id: this.education?.category_id || '',
|
||||
target_group: this.education?.target_group || '',
|
||||
qualification: this.education?.qualification || '',
|
||||
price: this.education?.price || '',
|
||||
learning_time: this.education?.learning_time || '',
|
||||
form_education: this.education?.form_education || '',
|
||||
is_active: this.education?.is_active ?? true,
|
||||
content: this.education?.content || []
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (this.form.title && !this.education) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
const routeName = this.submitRoute;
|
||||
const method = this.submitMethod;
|
||||
|
||||
if (method === 'put') {
|
||||
this.$inertia.put(route(routeName, this.education.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.$inertia.post(route(routeName), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.categories.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Создание категории ДПО</h1>
|
||||
<p class="text-xs text-muted-foreground-1">Заполните информацию о новой категории</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название категории <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@blur="generateSlug"
|
||||
placeholder='Например: "Профессиональная переподготовка"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-идентификатор <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
readonly
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-muted/30 border rounded-lg text-sm text-foreground cursor-not-allowed',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dir_addit_educat_id" class="block text-sm font-medium text-foreground mb-2">
|
||||
Направление ДПО <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="dir_addit_educat_id"
|
||||
v-model="form.dir_addit_educat_id"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.dir_addit_educat_id ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите направление</option>
|
||||
<option v-for="direction in directions" :key="direction.id" :value="direction.id">
|
||||
{{ direction.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.dir_addit_educat_id" class="mt-1.5 text-xs text-rose-500">{{ errors.dir_addit_educat_id }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активная категория
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="is_active"
|
||||
@click="form.is_active = !form.is_active"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
form.is_active ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
form.is_active ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground-1">Отображать ли категорию на сайте</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.categories.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Создать категорию' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'CategoryCreate',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
props: {
|
||||
directions: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
slug: '',
|
||||
dir_addit_educat_id: '',
|
||||
is_active: true
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание категории ДПО');
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (this.form.title) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.post(route('dashboard.additional-educations.categories.store'), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.categories.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Редактирование категории ДПО</h1>
|
||||
<p class="text-xs text-muted-foreground-1">{{ category?.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название категории <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@blur="generateSlug"
|
||||
placeholder='Например: "Профессиональная переподготовка"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-идентификатор <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
readonly
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-muted/30 border rounded-lg text-sm text-foreground cursor-not-allowed',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dir_addit_educat_id" class="block text-sm font-medium text-foreground mb-2">
|
||||
Направление ДПО <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="dir_addit_educat_id"
|
||||
v-model="form.dir_addit_educat_id"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.dir_addit_educat_id ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите направление</option>
|
||||
<option v-for="direction in directions" :key="direction.id" :value="direction.id">
|
||||
{{ direction.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.dir_addit_educat_id" class="mt-1.5 text-xs text-rose-500">{{ errors.dir_addit_educat_id }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активная категория
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="is_active"
|
||||
@click="form.is_active = !form.is_active"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
form.is_active ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
form.is_active ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground-1">Отображать ли категорию на сайте</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.categories.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'CategoryEdit',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
props: {
|
||||
category: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
directions: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: this.category?.title || '',
|
||||
slug: this.category?.slug || '',
|
||||
dir_addit_educat_id: this.category?.dir_addit_educat_id || '',
|
||||
is_active: this.category?.is_active ?? true
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE(`Редактирование категории - ${this.category?.title}`);
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (this.form.title && !this.form.slug) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.put(route('dashboard.additional-educations.categories.update', this.category.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="tag" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Категории ДПО</template>
|
||||
<template #header-subtitle>Управление категориями программ дополнительного образования</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.categories.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить категорию
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск"
|
||||
placeholder="Введите название категории..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="directionQuery"
|
||||
label="Направление"
|
||||
placeholder="Все направления"
|
||||
@change="filterByDirection"
|
||||
>
|
||||
<option v-for="direction in directions" :key="direction.id" :value="direction.id">
|
||||
{{ direction.title }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
|
||||
<SelectFilter
|
||||
v-model="isActiveQuery"
|
||||
label="Статус"
|
||||
placeholder="Все"
|
||||
@change="filterByActive"
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="1">Активные</option>
|
||||
<option value="0">Неактивные</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ categories.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ categories.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Направление
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="category in categories.data"
|
||||
:key="category.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ TEXT_LIMIT(category.title, 50) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground-1 mt-0.5">
|
||||
/{{ category.slug }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border bg-primary/10 text-primary border-primary/20">
|
||||
{{ category.direction?.title || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="STATUS_BADGE_CLASS(category.is_active)">
|
||||
{{ category.is_active ? 'Активна' : 'Неактивна' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.categories.edit', category.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="deleteCategory(category)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmptyState
|
||||
v-if="categories.data.length === 0"
|
||||
:columns="4"
|
||||
title="Категории не найдены"
|
||||
description="Создайте первую категорию дополнительного образования или измените параметры поиска"
|
||||
:action-url="route('dashboard.additional-educations.categories.create')"
|
||||
action-text="Добавить категорию"
|
||||
icon-path="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination :data="categories" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'CategoryIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
categories: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
direction_id: '',
|
||||
is_active: '',
|
||||
search: ''
|
||||
})
|
||||
},
|
||||
directions: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
directionQuery: this.filters?.direction_id || '',
|
||||
isActiveQuery: this.filters?.is_active || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Категории ДПО');
|
||||
},
|
||||
|
||||
methods: {
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.additional-educations.categories.index', {
|
||||
search: this.searchQuery,
|
||||
direction_id: this.directionQuery,
|
||||
is_active: this.isActiveQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByDirection() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
filterByActive() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'directionQuery', 'isActiveQuery'],
|
||||
'dashboard.additional-educations.categories.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
deleteCategory(category) {
|
||||
this.CONFIRM_AND_DELETE(category, 'dashboard.additional-educations.categories.destroy', {
|
||||
message: 'Удалить категорию "' + category.title + '"?'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Создание программы ДПО</h1>
|
||||
<p class="text-xs text-muted-foreground-1">Заполните информацию о новой программе дополнительного образования</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<AdditionalEducationForm
|
||||
:categories="categories"
|
||||
:education-forms="educationForms"
|
||||
submit-label="Создать программу"
|
||||
submit-route="dashboard.additional-educations.store"
|
||||
submit-method="post"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import AdditionalEducationForm from './AdditionalEducationForm.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdditionalEducationCreate',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
AdditionalEducationForm
|
||||
},
|
||||
|
||||
props: {
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
educationForms: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание программы ДПО');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.directions.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Создание направления ДПО</h1>
|
||||
<p class="text-xs text-muted-foreground-1">Заполните информацию о новом направлении</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название направления <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@blur="generateSlug"
|
||||
placeholder='Например: "Информационные технологии"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-идентификатор <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
readonly
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-muted/30 border rounded-lg text-sm text-foreground cursor-not-allowed',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активное направление
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="is_active"
|
||||
@click="form.is_active = !form.is_active"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
form.is_active ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
form.is_active ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground-1">Отображать ли направление на сайте</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.directions.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Создать направление' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'DirectionCreate',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
slug: '',
|
||||
is_active: true
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание направления ДПО');
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (this.form.title) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.post(route('dashboard.additional-educations.directions.store'), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.directions.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Редактирование направления ДПО</h1>
|
||||
<p class="text-xs text-muted-foreground-1">{{ direction?.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название направления <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@blur="generateSlug"
|
||||
placeholder='Например: "Информационные технологии"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-идентификатор <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
readonly
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-muted/30 border rounded-lg text-sm text-foreground cursor-not-allowed',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активное направление
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="is_active"
|
||||
@click="form.is_active = !form.is_active"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
form.is_active ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
form.is_active ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground-1">Отображать ли направление на сайте</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.directions.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'DirectionEdit',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
props: {
|
||||
direction: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: this.direction?.title || '',
|
||||
slug: this.direction?.slug || '',
|
||||
is_active: this.direction?.is_active ?? true
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE(`Редактирование направления - ${this.direction?.title}`);
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
// Не перегенерируем slug при редактировании если он уже есть
|
||||
if (this.form.title && !this.form.slug) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.put(route('dashboard.additional-educations.directions.update', this.direction.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="arrow-trending-up" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Направления ДПО</template>
|
||||
<template #header-subtitle>Управление направлениями дополнительного образования</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.directions.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить направление
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск"
|
||||
placeholder="Введите название направления..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="isActiveQuery"
|
||||
label="Статус"
|
||||
placeholder="Все"
|
||||
@change="filterByActive"
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="1">Активные</option>
|
||||
<option value="0">Неактивные</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ directions.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ directions.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="direction in directions.data"
|
||||
:key="direction.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ TEXT_LIMIT(direction.title, 60) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground-1 mt-0.5">
|
||||
/{{ direction.slug }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="STATUS_BADGE_CLASS(direction.is_active)">
|
||||
{{ direction.is_active ? 'Активно' : 'Неактивно' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.directions.edit', direction.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="deleteDirection(direction)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmptyState
|
||||
v-if="directions.data.length === 0"
|
||||
:columns="3"
|
||||
title="Направления не найдены"
|
||||
description="Создайте первое направление дополнительного образования или измените параметры поиска"
|
||||
:action-url="route('dashboard.additional-educations.directions.create')"
|
||||
action-text="Добавить направление"
|
||||
icon-path="M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H6.911a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination :data="directions" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'DirectionIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
directions: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
is_active: '',
|
||||
search: ''
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
isActiveQuery: this.filters?.is_active || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Направления ДПО');
|
||||
},
|
||||
|
||||
methods: {
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.additional-educations.directions.index', {
|
||||
search: this.searchQuery,
|
||||
is_active: this.isActiveQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByActive() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'isActiveQuery'],
|
||||
'dashboard.additional-educations.directions.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
deleteDirection(direction) {
|
||||
this.CONFIRM_AND_DELETE(direction, 'dashboard.additional-educations.directions.destroy', {
|
||||
message: 'Удалить направление "' + direction.title + '"?'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Редактирование программы ДПО</h1>
|
||||
<p class="text-xs text-muted-foreground-1">{{ education?.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<AdditionalEducationForm
|
||||
:education="education"
|
||||
:categories="categories"
|
||||
:education-forms="educationForms"
|
||||
submit-label="Сохранить изменения"
|
||||
submit-route="dashboard.additional-educations.update"
|
||||
submit-method="put"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import AdditionalEducationForm from './AdditionalEducationForm.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdditionalEducationEdit',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
AdditionalEducationForm
|
||||
},
|
||||
|
||||
props: {
|
||||
education: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
educationForms: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE(`Редактирование программы ДПО - ${this.education?.title}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="book-open" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Дополнительное образование</template>
|
||||
<template #header-subtitle>Управление программами ДПО</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить программу
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Filters Card -->
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск"
|
||||
placeholder="Введите название или целевую аудиторию..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="categoryQuery"
|
||||
label="Категория"
|
||||
placeholder="Все категории"
|
||||
@change="filterByCategory"
|
||||
>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.title }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
|
||||
<SelectFilter
|
||||
v-model="formEducationQuery"
|
||||
label="Форма обучения"
|
||||
placeholder="Все формы"
|
||||
@change="filterByFormEducation"
|
||||
>
|
||||
<option v-for="form in educationForms" :key="form.value" :value="form.value">
|
||||
{{ EducationForm.fromValue(form.value)?.label || getFormEducationLabel(form.value) }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
|
||||
<SelectFilter
|
||||
v-model="isActiveQuery"
|
||||
label="Статус"
|
||||
placeholder="Все"
|
||||
@change="filterByActive"
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="1">Активные</option>
|
||||
<option value="0">Неактивные</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<!-- Programs Table -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Table Header Stats -->
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ educations.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ educations.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Категория
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Стоимость
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Часов
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Форма обучения
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="education in educations.data"
|
||||
:key="education.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ TEXT_LIMIT(education.title, 50) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground-1 mt-0.5">
|
||||
{{ TEXT_LIMIT(education.target_group, 40) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border bg-primary/10 text-primary border-primary/20">
|
||||
{{ education.category?.title || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-foreground">{{ formatPrice(education.price) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="text-sm text-foreground">{{ education.learning_time }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getFormEducationBadgeClass(education.form_education)">
|
||||
{{ EducationForm.fromValue(education.form_education)?.label || getFormEducationLabel(education.form_education) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="STATUS_BADGE_CLASS(education.is_active)">
|
||||
{{ education.is_active ? 'Активна' : 'Неактивна' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.additional-educations.edit', education.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="deleteEducation(education)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-if="educations.data.length === 0"
|
||||
:columns="7"
|
||||
title="Программы не найдены"
|
||||
description="Создайте первую программу дополнительного образования или измените параметры поиска"
|
||||
:action-url="route('dashboard.additional-educations.create')"
|
||||
action-text="Добавить программу"
|
||||
icon-path="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :data="educations" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EducationForm from '@/Enum/EducationForm.js';
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdditionalEducationIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
educations: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
category_id: '',
|
||||
form_education: '',
|
||||
is_active: '',
|
||||
search: ''
|
||||
})
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
educationForms: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
categoryQuery: this.filters?.category_id || '',
|
||||
formEducationQuery: this.filters?.form_education || '',
|
||||
isActiveQuery: this.filters?.is_active || '',
|
||||
EducationForm
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Дополнительное образование');
|
||||
},
|
||||
|
||||
methods: {
|
||||
getFormEducationBadgeClass(formId) {
|
||||
const eduForm = EducationForm.fromValue(formId);
|
||||
const colorClasses = {
|
||||
1: 'bg-success/10 text-success border-success/20',
|
||||
2: 'bg-info/10 text-info border-info/20',
|
||||
3: 'bg-warning/10 text-warning border-warning/20'
|
||||
};
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border';
|
||||
return `${baseClasses} ${colorClasses[formId] || colorClasses[1]}`;
|
||||
},
|
||||
|
||||
getFormEducationLabel(formId) {
|
||||
const eduForm = EducationForm.fromValue(formId);
|
||||
return eduForm ? eduForm.label : '—';
|
||||
},
|
||||
|
||||
formatPrice(price) {
|
||||
if (!price) return '—';
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(price);
|
||||
},
|
||||
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.additional-educations.index', {
|
||||
search: this.searchQuery,
|
||||
category_id: this.categoryQuery,
|
||||
form_education: this.formEducationQuery,
|
||||
is_active: this.isActiveQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByCategory() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
filterByFormEducation() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
filterByActive() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'categoryQuery', 'formEducationQuery', 'isActiveQuery'],
|
||||
'dashboard.additional-educations.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
deleteEducation(education) {
|
||||
this.CONFIRM_AND_DELETE(education, 'dashboard.additional-educations.destroy', {
|
||||
message: 'Удалить программу "' + education.title + '"?'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.admission-campaigns.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Создание приемной кампании</h1>
|
||||
<p class="text-xs text-muted-foreground-1">Заполните информацию о новой кампании</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название кампании <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder='Например: "Приемная кампания 2024"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.name ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1.5 text-xs text-rose-500">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="academic_year" class="block text-sm font-medium text-foreground mb-2">
|
||||
Академический год <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="academic_year"
|
||||
v-model="form.academic_year"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.academic_year ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите учебный год</option>
|
||||
<option v-for="year in academicYears" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.academic_year" class="mt-1.5 text-xs text-rose-500">{{ errors.academic_year }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-foreground mb-2">
|
||||
Статус кампании <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.status ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите статус</option>
|
||||
<option v-for="status in statuses" :key="status.value" :value="status.value">
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.status" class="mt-1.5 text-xs text-rose-500">{{ errors.status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.admission-campaigns.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Создать кампанию' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdmissionCampaignCreate',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
props: {
|
||||
statuses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
academicYears: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
name: '',
|
||||
academic_year: '',
|
||||
status: '',
|
||||
info: []
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание приемной кампании');
|
||||
},
|
||||
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.post(route('dashboard.admission-campaigns.store'), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.admission-campaigns.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Редактирование приемной кампании</h1>
|
||||
<p class="text-xs text-muted-foreground-1">{{ campaign?.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<!-- Main Info -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название кампании <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder='Например: "Приемная кампания 2024"'
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.name ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1.5 text-xs text-rose-500">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="academic_year" class="block text-sm font-medium text-foreground mb-2">
|
||||
Академический год <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="academic_year"
|
||||
v-model="form.academic_year"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.academic_year ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите учебный год</option>
|
||||
<option v-for="year in academicYears" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.academic_year" class="mt-1.5 text-xs text-rose-500">{{ errors.academic_year }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-foreground mb-2">
|
||||
Статус кампании <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.status ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
>
|
||||
<option value="">Выберите статус</option>
|
||||
<option v-for="status in statuses" :key="status.value" :value="status.value">
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.status" class="mt-1.5 text-xs text-rose-500">{{ errors.status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Repeater -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="academic-cap" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Информация о наборе</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="addInfoItem"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all shadow-sm"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить уровень образования
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">
|
||||
Данные о программах и местах для разных уровней образования
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div v-if="form.info.length === 0" class="text-center py-12 bg-surface border border-layer-line rounded-lg">
|
||||
<DashboardIcon name="academic-cap" size="12" class="text-muted-foreground-2 mx-auto mb-4" />
|
||||
<p class="text-foreground font-medium">Нет уровней образования</p>
|
||||
<p class="text-sm text-muted-foreground-1 mt-1 mb-4">Добавьте уровни образования для формирования набора</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="addInfoItem"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить первый уровень
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(item, index) in form.info"
|
||||
:key="index"
|
||||
class="group bg-layer border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Item Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-surface/50 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 bg-surface-muted text-muted-foreground-1 text-xs font-medium rounded">
|
||||
#{{ index + 1 }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ getLevelEducationalLabel(item.edu_name) || 'Уровень образования' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateInfoItem(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Дублировать"
|
||||
>
|
||||
<DashboardIcon name="square-2-stack" size="4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeInfoItem(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Content -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Level Education -->
|
||||
<div>
|
||||
<label :for="`edu_name_${index}`" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
Уровень образования <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
:id="`edu_name_${index}`"
|
||||
v-model="item.edu_name"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
>
|
||||
<option value="">Выберите уровень</option>
|
||||
<option v-for="level in levelEducationalOptions" :key="level.value" :value="level.value">
|
||||
{{ level.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Programs Count -->
|
||||
<div>
|
||||
<label :for="`total_programs_${index}`" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
Количество программ <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`total_programs_${index}`"
|
||||
v-model.number="item.total_programs"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Places Distribution -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label :for="`och_count_${index}`" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
Очная форма <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`och_count_${index}`"
|
||||
v-model.number="item.och_count"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`zaoch_count_${index}`" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
Заочная форма <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`zaoch_count_${index}`"
|
||||
v-model.number="item.zaoch_count"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`budget_places_${index}`" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
Бюджетные места <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`budget_places_${index}`"
|
||||
v-model.number="item.budget_places"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :for="`non_budget_places_${index}`" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
Платные места <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`non_budget_places_${index}`"
|
||||
v-model.number="item.non_budget_places"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.admission-campaigns.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdmissionCampaignEdit',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
props: {
|
||||
campaign: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
academicYears: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
levelEducationalOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: 1, label: 'Подготовка квалифицированных рабочих, служащих' },
|
||||
{ value: 2, label: 'Среднее профессиональное образование' },
|
||||
{ value: 3, label: 'Бакалавриат' },
|
||||
{ value: 4, label: 'Магистратура' },
|
||||
{ value: 5, label: 'Специалитет' },
|
||||
{ value: 6, label: 'Аспирантура' },
|
||||
{ value: 7, label: 'Адъюнктура' },
|
||||
{ value: 8, label: 'Ординатура' },
|
||||
{ value: 9, label: 'Ассистентура - стажировка' },
|
||||
{ value: 10, label: 'Профессиональная подготовка по профессиям рабочих, должностям служащих' },
|
||||
{ value: 11, label: 'Переподготовка рабочих, служащих' },
|
||||
{ value: 12, label: 'Повышение квалификации рабочих, служащих' },
|
||||
{ value: 13, label: 'Дополнительная общеразвивающая программа' },
|
||||
{ value: 14, label: 'Дополнительная предпрофессиональная программа' },
|
||||
{ value: 15, label: 'Дополнительная предпрофессиональная программа в сфере искусств' },
|
||||
{ value: 16, label: 'Повышение квалификации' },
|
||||
{ value: 17, label: 'Профессиональная переподготовка' },
|
||||
{ value: 18, label: 'Дошкольное образование' },
|
||||
{ value: 19, label: 'Начальное общее образование' },
|
||||
{ value: 20, label: 'Основное общее образование' },
|
||||
{ value: 21, label: 'Среднее общее образование' },
|
||||
{ value: 22, label: 'Интернатура' },
|
||||
{ value: 23, label: 'Дополнительная предпрофессиональная программа в сфере физической культуры и спорта' },
|
||||
{ value: 24, label: 'Базовое высшее образование' },
|
||||
{ value: 25, label: 'Специализированное высшее образование' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
name: this.campaign?.name || '',
|
||||
academic_year: this.campaign?.academic_year || '',
|
||||
status: this.campaign?.status || '',
|
||||
info: Array.isArray(this.campaign?.info) ? JSON.parse(JSON.stringify(this.campaign.info)) : []
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE(`Редактирование кампании - ${this.campaign?.name}`);
|
||||
},
|
||||
|
||||
methods: {
|
||||
getLevelEducationalLabel(value) {
|
||||
const level = this.levelEducationalOptions.find(l => l.value === value);
|
||||
return level ? level.label : null;
|
||||
},
|
||||
|
||||
addInfoItem() {
|
||||
this.form.info.push({
|
||||
edu_name: '',
|
||||
total_programs: 0,
|
||||
och_count: 0,
|
||||
zaoch_count: 0,
|
||||
budget_places: 0,
|
||||
non_budget_places: 0
|
||||
});
|
||||
},
|
||||
|
||||
removeInfoItem(index) {
|
||||
this.form.info.splice(index, 1);
|
||||
},
|
||||
|
||||
duplicateInfoItem(index) {
|
||||
const item = JSON.parse(JSON.stringify(this.form.info[index]));
|
||||
this.form.info.splice(index + 1, 0, item);
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.put(route('dashboard.admission-campaigns.update', this.campaign.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="clipboard-document-check" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Приемные кампании</template>
|
||||
<template #header-subtitle>Управление приемными кампаниями</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.admission-campaigns.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Создать кампанию
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск"
|
||||
placeholder="Введите название кампании..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="statusQuery"
|
||||
label="Статус"
|
||||
placeholder="Все статусы"
|
||||
@change="filterByStatus"
|
||||
>
|
||||
<option v-for="status in statuses" :key="status.value" :value="status.value">
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
|
||||
<SelectFilter
|
||||
v-model="academicYearQuery"
|
||||
label="Учебный год"
|
||||
placeholder="Все годы"
|
||||
@change="filterByAcademicYear"
|
||||
>
|
||||
<option v-for="year in academicYears" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ campaigns.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ campaigns.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Уровней образования
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="campaign in campaigns.data"
|
||||
:key="campaign.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ TEXT_LIMIT(campaign.name, 50) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground-1 mt-0.5">
|
||||
{{ campaign.academic_year }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getStatusBadgeClass(campaign.status)">
|
||||
{{ getStatusLabel(campaign.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-surface-muted text-foreground">
|
||||
{{ getInfoCount(campaign) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.admission-campaigns.edit', campaign.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="deleteCampaign(campaign)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmptyState
|
||||
v-if="campaigns.data.length === 0"
|
||||
:columns="4"
|
||||
title="Приемные кампании не найдены"
|
||||
description="Создайте первую приемную кампанию или измените параметры поиска"
|
||||
:action-url="route('dashboard.admission-campaigns.create')"
|
||||
action-text="Создать кампанию"
|
||||
icon-path="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination :data="campaigns" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdmissionCampaignIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
campaigns: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
status: '',
|
||||
academic_year: '',
|
||||
search: ''
|
||||
})
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
academicYears: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
statusQuery: this.filters?.status || '',
|
||||
academicYearQuery: this.filters?.academic_year || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Приемные кампании');
|
||||
},
|
||||
|
||||
methods: {
|
||||
getStatusBadgeClass(status) {
|
||||
const statusMap = {
|
||||
1: 'bg-success/10 text-success border-success/20',
|
||||
2: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
|
||||
3: 'bg-danger/10 text-danger border-danger/20'
|
||||
};
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border';
|
||||
return `${baseClasses} ${statusMap[status] || statusMap[1]}`;
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
const statusObj = this.statuses.find(s => s.value === status);
|
||||
return statusObj ? statusObj.label : '—';
|
||||
},
|
||||
|
||||
getInfoCount(campaign) {
|
||||
return Array.isArray(campaign.info) ? campaign.info.length : 0;
|
||||
},
|
||||
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.admission-campaigns.index', {
|
||||
search: this.searchQuery,
|
||||
status: this.statusQuery,
|
||||
academic_year: this.academicYearQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByStatus() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
filterByAcademicYear() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'statusQuery', 'academicYearQuery'],
|
||||
'dashboard.admission-campaigns.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
deleteCampaign(campaign) {
|
||||
this.CONFIRM_AND_DELETE(campaign, 'dashboard.admission-campaigns.destroy', {
|
||||
message: 'Удалить кампанию "' + campaign.name + '"?'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<EditForm
|
||||
:admission-campaigns="admissionCampaigns"
|
||||
:educational-programs="educationalPrograms"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EditForm from './Edit.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdmissionPlanCreate',
|
||||
components: { EditForm },
|
||||
props: {
|
||||
admissionCampaigns: { type: Array, required: true },
|
||||
educationalPrograms: { type: Array, required: true }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a :href="route('dashboard.admission-plans.index')" class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all">
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon :name="isEdit ? 'pencil-square' : 'plus'" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">{{ isEdit ? 'Редактирование плана приема' : 'Создание плана приема' }}</h1>
|
||||
<p class="text-xs text-muted-foreground-1">{{ isEdit ? plan?.educational_program?.name : 'Заполните все необходимые поля' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<FlashMessages />
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<!-- Main Selection -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="information-circle" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основные настройки</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<label for="educational_programs_id" class="block text-sm font-medium text-foreground mb-2">
|
||||
Образовательная программа <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select id="educational_programs_id" v-model="form.educational_programs_id"
|
||||
:class="['w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all', errors.educational_programs_id ? 'border-rose-500' : 'border-layer-line focus:border-primary']">
|
||||
<option value="">Выберите образовательную программу</option>
|
||||
<option v-for="program in educationalPrograms" :key="program.id" :value="program.id">{{ program.name }}</option>
|
||||
</select>
|
||||
<p v-if="errors.educational_programs_id" class="mt-1.5 text-xs text-rose-500">{{ errors.educational_programs_id }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admission_campaigns_id" class="block text-sm font-medium text-foreground mb-2">
|
||||
Приемная кампания <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select id="admission_campaigns_id" v-model="form.admission_campaigns_id"
|
||||
:class="['w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all', errors.admission_campaigns_id ? 'border-rose-500' : 'border-layer-line focus:border-primary']">
|
||||
<option value="">Выберите приемную кампанию</option>
|
||||
<option v-for="campaign in admissionCampaigns" :key="campaign.id" :value="campaign.id">{{ campaign.name }} ({{ campaign.academic_year }})</option>
|
||||
</select>
|
||||
<p v-if="errors.admission_campaigns_id" class="mt-1.5 text-xs text-rose-500">{{ errors.admission_campaigns_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exams Repeater -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-check" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Вступительные испытания</h2>
|
||||
</div>
|
||||
<button type="button" @click="addExam" class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all shadow-sm">
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить испытание
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">Добавьте все необходимые вступительные испытания</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div v-if="form.exams.length === 0" class="text-center py-8 bg-surface border border-layer-line rounded-lg">
|
||||
<DashboardIcon name="document-check" size="10" class="text-muted-foreground-2 mx-auto mb-3" />
|
||||
<p class="text-foreground font-medium">Нет вступительных испытаний</p>
|
||||
<button type="button" @click="addExam" class="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all">
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить первое испытание
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="(exam, eIdx) in form.exams" :key="eIdx" class="group bg-layer border border-layer-line rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-surface/50 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 bg-surface-muted text-muted-foreground-1 text-xs font-medium rounded">#{{ eIdx + 1 }}</span>
|
||||
<span class="text-sm font-medium text-foreground">{{ exam.title || 'Новое испытание' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" @click="duplicateExam(eIdx)" class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all" title="Дублировать">
|
||||
<DashboardIcon name="square-2-stack" size="4" />
|
||||
</button>
|
||||
<button type="button" @click="removeExam(eIdx)" class="p-1.5 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded transition-all" title="Удалить">
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground-1 mb-1.5">Название предмета <span class="text-rose-500">*</span></label>
|
||||
<input v-model="exam.title" type="text" placeholder="Например: Математика" class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground-1 mb-1.5">Приоритет <span class="text-rose-500">*</span></label>
|
||||
<input v-model.number="exam.priority" type="number" min="0" class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exam Types -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-xs font-medium text-muted-foreground-1">Виды вступительного испытания</label>
|
||||
<button type="button" @click="addExamType(eIdx)" :disabled="(exam.types || []).length >= 2" class="text-xs text-primary hover:text-primary-hover disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
+ Добавить вид
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="(type, tIdx) in (exam.types || [])" :key="tIdx" class="flex items-center gap-3 p-3 bg-surface border border-layer-line rounded-lg mb-2">
|
||||
<select v-model="type.type" class="flex-1 px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all">
|
||||
<option value="">Тип испытания</option>
|
||||
<option :value="1">ЕГЭ</option>
|
||||
<option :value="2">ВИ, проводимое организацией самостоятельно</option>
|
||||
<option :value="3">Ср. балл документа об образовании</option>
|
||||
<option :value="4">Аккредитация</option>
|
||||
</select>
|
||||
<input v-model.number="type.min_ball" type="number" min="0" max="100" placeholder="Мин. балл" class="w-24 px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" />
|
||||
<button type="button" @click="removeExamType(eIdx, tIdx)" class="p-1.5 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded transition-all">
|
||||
<DashboardIcon name="x-mark" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contests Repeater -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-line-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="trophy" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Условия поступления</h2>
|
||||
</div>
|
||||
<button type="button" @click="addContest" class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all shadow-sm">
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">Добавьте группы с условиями поступления</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div v-if="form.contests.length === 0" class="text-center py-8 bg-surface border border-layer-line rounded-lg">
|
||||
<DashboardIcon name="trophy" size="10" class="text-muted-foreground-2 mx-auto mb-3" />
|
||||
<p class="text-foreground font-medium">Нет условий поступления</p>
|
||||
<button type="button" @click="addContest" class="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all">
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить первую группу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="(contest, cIdx) in form.contests" :key="cIdx" class="group bg-layer border border-layer-line rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-surface/50 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 bg-surface-muted text-muted-foreground-1 text-xs font-medium rounded">#{{ cIdx + 1 }}</span>
|
||||
<span class="text-sm font-medium text-foreground">{{ getFormEducationLabel(contest.form_education) || 'Условия поступления' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" @click="duplicateContest(cIdx)" class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all" title="Дублировать">
|
||||
<DashboardIcon name="square-2-stack" size="4" />
|
||||
</button>
|
||||
<button type="button" @click="removeContest(cIdx)" class="p-1.5 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded transition-all" title="Удалить">
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground-1 mb-1.5">Форма обучения <span class="text-rose-500">*</span></label>
|
||||
<select v-model="contest.form_education" class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all">
|
||||
<option value="">Выберите форму</option>
|
||||
<option :value="1">Очная форма обучения</option>
|
||||
<option :value="2">Очно-заочная форма обучения</option>
|
||||
<option :value="3">Заочная форма обучения</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground-1 mb-1.5">Форма финансирования <span class="text-rose-500">*</span></label>
|
||||
<select v-model="contest.places.form_budget" class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all">
|
||||
<option value="">Выберите тип</option>
|
||||
<option :value="1">Основные места</option>
|
||||
<option :value="2">Целевая квота</option>
|
||||
<option :value="3">Особая квота</option>
|
||||
<option :value="4">С оплатой обучения</option>
|
||||
<option :value="5">За счёт иных средств</option>
|
||||
<option :value="6">Отдельная квота</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground-1 mb-1.5">Количество мест <span class="text-rose-500">*</span></label>
|
||||
<input v-model.number="contest.places.count" type="number" min="0" class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a :href="route('dashboard.admission-plans.index')" class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all">Отмена</a>
|
||||
<button type="submit" :disabled="processing" class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><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" /></svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : (isEdit ? 'Сохранить изменения' : 'Создать план') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdmissionPlanForm',
|
||||
components: { DashboardIcon, FlashMessages },
|
||||
props: {
|
||||
plan: { type: Object, default: null },
|
||||
admissionCampaigns: { type: Array, required: true },
|
||||
educationalPrograms: { type: Array, required: true }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
educational_programs_id: this.plan?.educational_programs_id || '',
|
||||
admission_campaigns_id: this.plan?.admission_campaigns_id || '',
|
||||
exams: this.plan?.exams ? JSON.parse(JSON.stringify(this.plan.exams)) : [],
|
||||
contests: this.plan?.contests ? JSON.parse(JSON.stringify(this.plan.contests)) : []
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isEdit() { return !!this.plan; }
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE(this.isEdit ? `Редактирование плана приема` : 'Создание плана приема');
|
||||
},
|
||||
methods: {
|
||||
getFormEducationLabel(value) {
|
||||
const labels = { 1: 'Очная', 2: 'Очно-заочная', 3: 'Заочная' };
|
||||
return labels[value] || null;
|
||||
},
|
||||
|
||||
// Exams
|
||||
addExam() { this.form.exams.push({ title: '', priority: 0, types: [] }); },
|
||||
removeExam(idx) { this.form.exams.splice(idx, 1); },
|
||||
duplicateExam(idx) {
|
||||
const item = JSON.parse(JSON.stringify(this.form.exams[idx]));
|
||||
this.form.exams.splice(idx + 1, 0, item);
|
||||
},
|
||||
addExamType(examIdx) {
|
||||
if (!this.form.exams[examIdx].types) this.form.exams[examIdx].types = [];
|
||||
if (this.form.exams[examIdx].types.length < 2) {
|
||||
this.form.exams[examIdx].types.push({ type: '', min_ball: 0 });
|
||||
}
|
||||
},
|
||||
removeExamType(examIdx, typeIdx) {
|
||||
this.form.exams[examIdx].types.splice(typeIdx, 1);
|
||||
},
|
||||
|
||||
// Contests
|
||||
addContest() { this.form.contests.push({ form_education: '', places: { form_budget: '', count: 0 } }); },
|
||||
removeContest(idx) { this.form.contests.splice(idx, 1); },
|
||||
duplicateContest(idx) {
|
||||
const item = JSON.parse(JSON.stringify(this.form.contests[idx]));
|
||||
this.form.contests.splice(idx + 1, 0, item);
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
const url = this.isEdit
|
||||
? route('dashboard.admission-plans.update', this.plan.id)
|
||||
: route('dashboard.admission-plans.store');
|
||||
const method = this.isEdit ? 'put' : 'post';
|
||||
|
||||
this.$inertia[method](url, this.form, {
|
||||
onFinish: () => { this.processing = false; },
|
||||
onError: (errors) => { this.errors = errors; }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="rectangle-stack" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Планы приема</template>
|
||||
<template #header-subtitle>Управление планами приема</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.admission-plans.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Добавить план
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SelectFilter
|
||||
v-model="campaignQuery"
|
||||
label="Приемная кампания"
|
||||
placeholder="Все кампании"
|
||||
@change="filterByCampaign"
|
||||
>
|
||||
<option v-for="campaign in admissionCampaigns" :key="campaign.id" :value="campaign.id">
|
||||
{{ campaign.name }} ({{ campaign.academic_year }})
|
||||
</option>
|
||||
</SelectFilter>
|
||||
|
||||
<SelectFilter
|
||||
v-model="programQuery"
|
||||
label="Образовательная программа"
|
||||
placeholder="Все программы"
|
||||
@change="filterByProgram"
|
||||
>
|
||||
<option v-for="program in educationalPrograms" :key="program.id" :value="program.id">
|
||||
{{ program.name }}
|
||||
</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ plans.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ plans.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Программа
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Приемная кампания
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="plan in plans.data"
|
||||
:key="plan.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ TEXT_LIMIT(plan.educational_program?.name || '—', 60) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-foreground">
|
||||
{{ plan.admission_campaign?.academic_year || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.admission-plans.edit', plan.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="deletePlan(plan)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmptyState
|
||||
v-if="plans.data.length === 0"
|
||||
:columns="3"
|
||||
title="Планы приема не найдены"
|
||||
description="Создайте первый план приема или измените параметры поиска"
|
||||
:action-url="route('dashboard.admission-plans.create')"
|
||||
action-text="Добавить план"
|
||||
icon-path="M3.375 4.5C2.339 4.5 1.5 5.34 1.5 6.375v13.5c0 1.036.84 1.875 1.875 1.875h16.5c1.036 0 1.875-.84 1.875-1.875V6.375c0-1.036-.84-1.875-1.875-1.875H3.375z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination :data="plans" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../Components/shared/DataFilters.vue';
|
||||
import SelectFilter from '../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdmissionPlanIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
plans: { type: Object, required: true },
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({ admission_campaigns_id: '', educational_programs_id: '' })
|
||||
},
|
||||
admissionCampaigns: { type: Array, required: true },
|
||||
educationalPrograms: { type: Array, required: true }
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
campaignQuery: this.filters?.admission_campaigns_id || '',
|
||||
programQuery: this.filters?.educational_programs_id || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() { this.SET_DOCUMENT_TITLE('Планы приема'); },
|
||||
|
||||
methods: {
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.admission-plans.index', {
|
||||
admission_campaigns_id: this.campaignQuery,
|
||||
educational_programs_id: this.programQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByCampaign() { this.search(); },
|
||||
filterByProgram() { this.search(); },
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(['campaignQuery', 'programQuery'], 'dashboard.admission-plans.index');
|
||||
},
|
||||
|
||||
refreshPage() { this.search(); },
|
||||
|
||||
deletePlan(plan) {
|
||||
this.CONFIRM_AND_DELETE(plan, 'dashboard.admission-plans.destroy', {
|
||||
message: 'Удалить план приема для "' + (plan.educational_program?.name || '') + '"?'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-title>Создание категории</template>
|
||||
<template #header-subtitle>Новая категория для новостей</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-layer-line">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="tag" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">
|
||||
Заполните данные о категории новостей
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название категории <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
placeholder="Например: Новости института"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-primary bg-surface border-layer-line rounded focus:ring-primary/20"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активная категория
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-layer-line">
|
||||
<a
|
||||
:href="route('dashboard.categories.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Создать категорию' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'CategoryCreate',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
is_active: true,
|
||||
},
|
||||
errors: {},
|
||||
processing: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание категории');
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.post(route('dashboard.categories.store'), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-title>Редактирование категории</template>
|
||||
<template #header-subtitle>{{ category.title }}</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-6 border-b border-layer-line">
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="tag" size="5" class="text-primary" />
|
||||
<h2 class="text-base font-medium text-foreground">Основная информация</h2>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground-1 mt-1">
|
||||
Обновите данные категории
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название категории <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
placeholder="Например: Новости института"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Slug (readonly) -->
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
:value="category.slug"
|
||||
type="text"
|
||||
readonly
|
||||
class="w-full px-4 py-2.5 bg-muted border border-layer-line rounded-lg text-sm text-muted-foreground-1 cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground-1">
|
||||
Генерируется автоматически из названия
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-primary bg-surface border-layer-line rounded focus:ring-primary/20"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активная категория
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-layer-line">
|
||||
<a
|
||||
:href="route('dashboard.categories.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'CategoryEdit',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
},
|
||||
props: {
|
||||
category: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: this.category.title || '',
|
||||
is_active: Boolean(this.category.is_active),
|
||||
},
|
||||
errors: {},
|
||||
processing: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Редактирование категории');
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.put(route('dashboard.categories.update', this.category.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="tag" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Категории новостей</template>
|
||||
<template #header-subtitle>Управление категориями публикаций</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.categories.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Создать категорию
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Filters Card -->
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск по названию"
|
||||
placeholder="Введите название категории..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="statusQuery"
|
||||
label="Статус"
|
||||
placeholder="Все статусы"
|
||||
@change="search"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="1">Активные</option>
|
||||
<option value="0">Неактивные</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<!-- Categories Table -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Table Header Stats -->
|
||||
<div class="px-6 py-4 border-b border-layer-line bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ categories.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ categories.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-layer-line">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Slug
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Дата создания
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-layer-line">
|
||||
<tr
|
||||
v-for="category in categories.data"
|
||||
:key="category.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-muted-foreground-1">
|
||||
{{ category.id }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ category.title }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-muted-foreground-1 font-mono">
|
||||
{{ category.slug }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="STATUS_BADGE_CLASS(category.is_active)">
|
||||
{{ category.is_active ? 'Активна' : 'Неактивна' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-foreground">{{ FORMAT_DATE(category.created_at, 'short') }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.categories.edit', category.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="CONFIRM_AND_DELETE(category, 'dashboard.categories.destroy')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-if="categories.data.length === 0"
|
||||
:columns="6"
|
||||
title="Категории не найдены"
|
||||
description="Создайте первую категорию или измените параметры поиска"
|
||||
:action-url="route('dashboard.categories.create')"
|
||||
action-text="Создать категорию"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :data="categories" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'CategoryIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination,
|
||||
},
|
||||
props: {
|
||||
categories: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
search: '',
|
||||
is_active: '',
|
||||
}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
statusQuery: this.filters?.is_active || '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Категории новостей');
|
||||
},
|
||||
methods: {
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.categories.index', {
|
||||
search: this.searchQuery,
|
||||
is_active: this.statusQuery,
|
||||
});
|
||||
},
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'statusQuery'],
|
||||
'dashboard.categories.index'
|
||||
);
|
||||
},
|
||||
refreshPage() {
|
||||
this.$inertia.get(route('dashboard.categories.index'), {
|
||||
search: this.searchQuery,
|
||||
is_active: this.statusQuery,
|
||||
}, {
|
||||
preserveState: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border border-layer-line rounded-lg">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ label || 'Контент' }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = true"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Добавить блок
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blocks List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(block, index) in blocks"
|
||||
:key="block._uid"
|
||||
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Block Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<!-- Drag Handle -->
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-move text-muted-foreground-1 hover:text-foreground transition-colors"
|
||||
@mousedown="startDrag(index, $event)"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Block Icon & Label -->
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<component :is="getBlockIcon(block.type)" class="w-4 h-4 text-muted-foreground-1" />
|
||||
<span class="text-sm font-medium text-foreground">{{ getBlockLabel(block.type) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Клонировать"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
:title="collapsedBlocks.includes(index) ? 'Развернуть' : 'Свернуть'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-180': !collapsedBlocks.includes(index) }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Content -->
|
||||
<div v-show="!collapsedBlocks.includes(index)" class="p-4">
|
||||
<component
|
||||
:is="getBlockComponent(block.type)"
|
||||
v-model="block.data"
|
||||
:all-blocks="blocks"
|
||||
@update="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="blocks.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 border-2 border-dashed border-layer-line rounded-lg bg-muted/20"
|
||||
>
|
||||
<svg class="w-12 h-12 text-muted-foreground-1 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-foreground mb-1">Нет блоков контента</p>
|
||||
<p class="text-xs text-muted-foreground-1 text-center">Нажмите "Добавить блок" чтобы начать</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Picker Modal -->
|
||||
<div
|
||||
v-if="showBlockPicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showBlockPicker = false"
|
||||
>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-foreground">Выберите тип блока</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = false"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded transition-all"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="blockType in availableBlocks"
|
||||
:key="blockType.type"
|
||||
type="button"
|
||||
@click="addBlock(blockType.type)"
|
||||
class="flex flex-col items-center gap-2 p-4 border border-layer-line rounded-lg hover:border-primary hover:bg-primary/5 transition-all group"
|
||||
>
|
||||
<component :is="getBlockIcon(blockType.type)" class="w-8 h-8 text-muted-foreground-1 group-hover:text-primary transition-colors" />
|
||||
<span class="text-xs font-medium text-foreground text-center group-hover:text-primary transition-colors">
|
||||
{{ getBlockLabel(blockType.type) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
|
||||
// Block Components
|
||||
import HeadingBlock from './blocks/HeadingBlock.vue';
|
||||
import ParagraphBlock from './blocks/ParagraphBlock.vue';
|
||||
import ImageBlock from './blocks/ImageBlock.vue';
|
||||
import ImagesBlock from './blocks/ImagesBlock.vue';
|
||||
import FilesBlock from './blocks/FilesBlock.vue';
|
||||
import FastFilesBlock from './blocks/FastFilesBlock.vue';
|
||||
import VideoBlock from './blocks/VideoBlock.vue';
|
||||
import PersonBlock from './blocks/PersonBlock.vue';
|
||||
import StepperBlock from './blocks/StepperBlock.vue';
|
||||
import TabBlock from './blocks/TabBlock.vue';
|
||||
import SliderBlock from './blocks/SliderBlock.vue';
|
||||
import PostItemBlock from './blocks/PostItemBlock.vue';
|
||||
import PostListBlock from './blocks/PostListBlock.vue';
|
||||
import PageItemBlock from './blocks/PageItemBlock.vue';
|
||||
import PageResourceListBlock from './blocks/PageResourceListBlock.vue';
|
||||
import ContactBlock from './blocks/ContactBlock.vue';
|
||||
import CustomFormBlock from './blocks/CustomFormBlock.vue';
|
||||
|
||||
// Icons
|
||||
import IconHeading from './icons/IconHeading.vue';
|
||||
import IconParagraph from './icons/IconParagraph.vue';
|
||||
import IconImage from './icons/IconImage.vue';
|
||||
import IconImages from './icons/IconImages.vue';
|
||||
import IconFiles from './icons/IconFiles.vue';
|
||||
import IconVideo from './icons/IconVideo.vue';
|
||||
import IconPerson from './icons/IconPerson.vue';
|
||||
import IconStepper from './icons/IconStepper.vue';
|
||||
import IconTabs from './icons/IconTabs.vue';
|
||||
import IconSlider from './icons/IconSlider.vue';
|
||||
import IconPost from './icons/IconPost.vue';
|
||||
import IconPage from './icons/IconPage.vue';
|
||||
import IconContact from './icons/IconContact.vue';
|
||||
import IconForm from './icons/IconForm.vue';
|
||||
import IconArchive from './icons/IconArchive.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContentBuilder',
|
||||
components: {
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
ImageBlock,
|
||||
ImagesBlock,
|
||||
FilesBlock,
|
||||
FastFilesBlock,
|
||||
VideoBlock,
|
||||
PersonBlock,
|
||||
StepperBlock,
|
||||
TabBlock,
|
||||
SliderBlock,
|
||||
PostItemBlock,
|
||||
PostListBlock,
|
||||
PageItemBlock,
|
||||
PageResourceListBlock,
|
||||
ContactBlock,
|
||||
CustomFormBlock,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const blocks = ref([]);
|
||||
const collapsedBlocks = ref([]);
|
||||
const showBlockPicker = ref(false);
|
||||
let uidCounter = 0;
|
||||
|
||||
const availableBlocks = [
|
||||
{ type: 'heading' },
|
||||
{ type: 'paragraph' },
|
||||
{ type: 'image' },
|
||||
{ type: 'images' },
|
||||
{ type: 'files' },
|
||||
{ type: 'fast_files' },
|
||||
{ type: 'video' },
|
||||
{ type: 'person' },
|
||||
{ type: 'stepper' },
|
||||
{ type: 'tabs' },
|
||||
{ type: 'slider' },
|
||||
{ type: 'postItem' },
|
||||
{ type: 'postsList' },
|
||||
{ type: 'pageItem' },
|
||||
{ type: 'pageResourceList' },
|
||||
{ type: 'contact' },
|
||||
{ type: 'customForm' },
|
||||
];
|
||||
|
||||
const blockLabels = {
|
||||
heading: 'Заголовок',
|
||||
paragraph: 'Текст',
|
||||
image: 'Изображение',
|
||||
images: 'Слайдер изображений',
|
||||
files: 'Файлы',
|
||||
fast_files: 'Быстрая загрузка файлов',
|
||||
video: 'Видео',
|
||||
person: 'Персона',
|
||||
stepper: 'Этапы',
|
||||
tabs: 'Вкладки',
|
||||
slider: 'Слайдер',
|
||||
postItem: 'Конкретная новость',
|
||||
postsList: 'Список новостей',
|
||||
pageItem: 'Конкретная страница',
|
||||
pageResourceList: 'Ресурсы',
|
||||
contact: 'Контакты',
|
||||
customForm: 'Пользовательская форма',
|
||||
};
|
||||
|
||||
const blockIcons = {
|
||||
heading: IconHeading,
|
||||
paragraph: IconParagraph,
|
||||
image: IconImage,
|
||||
images: IconImages,
|
||||
files: IconFiles,
|
||||
fast_files: IconFiles,
|
||||
video: IconVideo,
|
||||
person: IconPerson,
|
||||
stepper: IconStepper,
|
||||
tabs: IconTabs,
|
||||
slider: IconSlider,
|
||||
postItem: IconPost,
|
||||
postsList: IconPost,
|
||||
pageItem: IconPage,
|
||||
pageResourceList: IconArchive,
|
||||
contact: IconContact,
|
||||
customForm: IconForm,
|
||||
};
|
||||
|
||||
const blockDefaults = {
|
||||
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
||||
paragraph: () => ({ seo_active: true, content: '' }),
|
||||
image: () => ({ url: '', alt: '' }),
|
||||
images: () => ({ url: [], alt: '' }),
|
||||
files: () => ({ file: [] }),
|
||||
fast_files: () => ({ path: [] }),
|
||||
video: () => ({ mime: '', title: '', path: '' }),
|
||||
person: () => ({ name: '', photo: '', info: [{ column: '', content: '' }] }),
|
||||
stepper: () => ({ step_name: '', steps: [{ title: '', content: '' }] }),
|
||||
tabs: () => ({ settings: { is_accordion: false }, tab: [{ title: '', content: [] }] }),
|
||||
slider: () => ({ slider: '' }),
|
||||
postItem: () => ({ post: null }),
|
||||
postsList: () => ({ count: 5, category: null }),
|
||||
pageItem: () => ({ page: null }),
|
||||
pageResourceList: () => ({ resource: '' }),
|
||||
contact: () => ({ contact: '' }),
|
||||
customForm: () => ({ form: '', settings: { in_modal: false } }),
|
||||
};
|
||||
|
||||
function generateUid() {
|
||||
return `block-${Date.now()}-${uidCounter++}`;
|
||||
}
|
||||
|
||||
function addBlock(type) {
|
||||
blocks.value.push({
|
||||
_uid: generateUid(),
|
||||
type,
|
||||
data: blockDefaults[type]()
|
||||
});
|
||||
showBlockPicker.value = false;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeBlock(index) {
|
||||
blocks.value.splice(index, 1);
|
||||
collapsedBlocks.value = collapsedBlocks.value.filter(i => i !== index);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function duplicateBlock(index) {
|
||||
const original = blocks.value[index];
|
||||
blocks.value.splice(index + 1, 0, {
|
||||
_uid: generateUid(),
|
||||
type: original.type,
|
||||
data: JSON.parse(JSON.stringify(original.data))
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function toggleBlock(index) {
|
||||
const idx = collapsedBlocks.value.indexOf(index);
|
||||
if (idx === -1) {
|
||||
collapsedBlocks.value.push(index);
|
||||
} else {
|
||||
collapsedBlocks.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockComponent(type) {
|
||||
const componentMap = {
|
||||
heading: 'HeadingBlock',
|
||||
paragraph: 'ParagraphBlock',
|
||||
image: 'ImageBlock',
|
||||
images: 'ImagesBlock',
|
||||
files: 'FilesBlock',
|
||||
fast_files: 'FastFilesBlock',
|
||||
video: 'VideoBlock',
|
||||
person: 'PersonBlock',
|
||||
stepper: 'StepperBlock',
|
||||
tabs: 'TabBlock',
|
||||
slider: 'SliderBlock',
|
||||
postItem: 'PostItemBlock',
|
||||
postsList: 'PostListBlock',
|
||||
pageItem: 'PageItemBlock',
|
||||
pageResourceList: 'PageResourceListBlock',
|
||||
contact: 'ContactBlock',
|
||||
customForm: 'CustomFormBlock',
|
||||
};
|
||||
return componentMap[type] || 'ParagraphBlock';
|
||||
}
|
||||
|
||||
function getBlockIcon(type) {
|
||||
return blockIcons[type] || IconParagraph;
|
||||
}
|
||||
|
||||
function getBlockLabel(type) {
|
||||
return blockLabels[type] || type;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const output = blocks.value.map(({ _uid, ...block }) => block);
|
||||
emit('update:modelValue', output);
|
||||
}
|
||||
|
||||
// Drag and Drop
|
||||
let dragIndex = null;
|
||||
|
||||
function startDrag(index, event) {
|
||||
dragIndex = index;
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
// Simplified drag logic - can be enhanced with a library like vuedraggable
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragIndex = null;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Initialize from modelValue
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
blocks.value = props.modelValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for modelValue changes (e.g., when loading existing content)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0 && blocks.value.length === 0) {
|
||||
// Only initialize if blocks are empty (initial load from server)
|
||||
blocks.value = newValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
collapsedBlocks,
|
||||
showBlockPicker,
|
||||
availableBlocks,
|
||||
addBlock,
|
||||
removeBlock,
|
||||
duplicateBlock,
|
||||
toggleBlock,
|
||||
getBlockComponent,
|
||||
getBlockIcon,
|
||||
getBlockLabel,
|
||||
emitChange,
|
||||
startDrag,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border border-layer-line rounded-lg">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ label || 'Поля формы' }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = true"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Добавить поле
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fields List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="field._uid"
|
||||
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Field Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<span class="text-xs font-mono text-muted-foreground-1 bg-muted px-2 py-0.5 rounded">{{ field.type }}</span>
|
||||
<span class="text-sm font-medium text-foreground">{{ field.data?.title_field || 'Без названия' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateField(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Клонировать"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleField(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
:title="collapsedFields.includes(index) ? 'Развернуть' : 'Свернуть'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-180': !collapsedFields.includes(index) }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeField(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Content -->
|
||||
<div v-show="!collapsedFields.includes(index)" class="p-4">
|
||||
<component
|
||||
:is="getFieldComponent(field.type)"
|
||||
v-model="field.data"
|
||||
@update="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="fields.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 border-2 border-dashed border-layer-line rounded-lg bg-muted/20"
|
||||
>
|
||||
<svg class="w-12 h-12 text-muted-foreground-1 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-foreground mb-1">Нет полей формы</p>
|
||||
<p class="text-xs text-muted-foreground-1 text-center">Нажмите "Добавить поле" чтобы начать</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Picker Modal -->
|
||||
<div
|
||||
v-if="showBlockPicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showBlockPicker = false"
|
||||
>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-foreground">Выберите тип поля</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = false"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded transition-all"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="fieldType in availableFields"
|
||||
:key="fieldType.type"
|
||||
type="button"
|
||||
@click="addField(fieldType.type)"
|
||||
class="flex flex-col items-center gap-2 p-4 border border-layer-line rounded-lg hover:border-primary hover:bg-primary/5 transition-all group"
|
||||
>
|
||||
<DashboardIcon :name="fieldType.icon" size="8" class="text-muted-foreground-1 group-hover:text-primary transition-colors" />
|
||||
<span class="text-xs font-medium text-foreground text-center group-hover:text-primary transition-colors">
|
||||
{{ fieldType.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
import FormFieldText from './FormBuilder/blocks/FormFieldText.vue';
|
||||
import FormFieldTextarea from './FormBuilder/blocks/FormFieldTextarea.vue';
|
||||
import FormFieldEmail from './FormBuilder/blocks/FormFieldEmail.vue';
|
||||
import FormFieldPhone from './FormBuilder/blocks/FormFieldPhone.vue';
|
||||
import FormFieldDate from './FormBuilder/blocks/FormFieldDate.vue';
|
||||
import FormFieldUrl from './FormBuilder/blocks/FormFieldUrl.vue';
|
||||
import FormFieldSingleChoice from './FormBuilder/blocks/FormFieldSingleChoice.vue';
|
||||
import FormFieldMultipleChoice from './FormBuilder/blocks/FormFieldMultipleChoice.vue';
|
||||
|
||||
export default {
|
||||
name: 'FormBuilder',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FormFieldText,
|
||||
FormFieldTextarea,
|
||||
FormFieldEmail,
|
||||
FormFieldPhone,
|
||||
FormFieldDate,
|
||||
FormFieldUrl,
|
||||
FormFieldSingleChoice,
|
||||
FormFieldMultipleChoice,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const fields = ref([]);
|
||||
const collapsedFields = ref([]);
|
||||
const showBlockPicker = ref(false);
|
||||
let uidCounter = 0;
|
||||
|
||||
const availableFields = [
|
||||
{ type: 'text', label: 'Короткий текст', icon: 'pencil' },
|
||||
{ type: 'textarea', label: 'Длинный текст', icon: 'document-text' },
|
||||
{ type: 'email', label: 'Email', icon: 'envelope' },
|
||||
{ type: 'phone', label: 'Телефон', icon: 'phone' },
|
||||
{ type: 'date', label: 'Дата', icon: 'calendar' },
|
||||
{ type: 'url', label: 'Ссылка', icon: 'link' },
|
||||
{ type: 'single_choice', label: 'Одиночный выбор', icon: 'radio' },
|
||||
{ type: 'multiple_choice', label: 'Множественный выбор', icon: 'check-circle' },
|
||||
];
|
||||
|
||||
const fieldDefaults = {
|
||||
text: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
textarea: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
email: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
phone: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
date: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
url: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
single_choice: () => ({ title_field: '', name_field: '', description: '', columns: [], rules: { required: false } }),
|
||||
multiple_choice: () => ({ title_field: '', name_field: '', description: '', columns: [], rules: { required: false } }),
|
||||
};
|
||||
|
||||
function generateUid() {
|
||||
return `field-${Date.now()}-${uidCounter++}`;
|
||||
}
|
||||
|
||||
function addField(type) {
|
||||
fields.value.push({
|
||||
_uid: generateUid(),
|
||||
type,
|
||||
data: fieldDefaults[type]()
|
||||
});
|
||||
showBlockPicker.value = false;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeField(index) {
|
||||
fields.value.splice(index, 1);
|
||||
collapsedFields.value = collapsedFields.value.filter(i => i !== index);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function duplicateField(index) {
|
||||
const original = fields.value[index];
|
||||
fields.value.splice(index + 1, 0, {
|
||||
_uid: generateUid(),
|
||||
type: original.type,
|
||||
data: JSON.parse(JSON.stringify(original.data))
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function toggleField(index) {
|
||||
const idx = collapsedFields.value.indexOf(index);
|
||||
if (idx === -1) {
|
||||
collapsedFields.value.push(index);
|
||||
} else {
|
||||
collapsedFields.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldComponent(type) {
|
||||
const componentMap = {
|
||||
text: 'FormFieldText',
|
||||
textarea: 'FormFieldTextarea',
|
||||
email: 'FormFieldEmail',
|
||||
phone: 'FormFieldPhone',
|
||||
date: 'FormFieldDate',
|
||||
url: 'FormFieldUrl',
|
||||
single_choice: 'FormFieldSingleChoice',
|
||||
multiple_choice: 'FormFieldMultipleChoice',
|
||||
};
|
||||
return componentMap[type] || 'FormFieldText';
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const output = fields.value.map(({ _uid, ...field }) => field);
|
||||
emit('update:modelValue', output);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
fields.value = props.modelValue.map(field => ({
|
||||
_uid: generateUid(),
|
||||
...field
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0 && fields.value.length === 0) {
|
||||
fields.value = newValue.map(field => ({
|
||||
_uid: generateUid(),
|
||||
...field
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return {
|
||||
fields,
|
||||
collapsedFields,
|
||||
showBlockPicker,
|
||||
availableFields,
|
||||
addField,
|
||||
removeField,
|
||||
duplicateField,
|
||||
toggleField,
|
||||
getFieldComponent,
|
||||
emitChange,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldDate', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldEmail', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldSingleChoice v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldSingleChoice from './FormFieldSingleChoice.vue';
|
||||
export default { name: 'FormFieldMultipleChoice', components: { FormFieldSingleChoice }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldPhone', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название группы <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="localData.title_field"
|
||||
type="text"
|
||||
@input="emitUpdate"
|
||||
placeholder="Например: Ваши интересы"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">Варианты выбора</label>
|
||||
<button type="button" @click="addOption" class="text-sm text-primary hover:text-primary-hover">+ Добавить вариант</button>
|
||||
</div>
|
||||
<div v-for="(option, i) in (localData.columns || [])" :key="i" class="flex items-center gap-2">
|
||||
<input v-model="option.title_field" @input="emitUpdate" type="text" placeholder="Вариант" class="flex-1 px-3 py-2 border border-layer-line rounded-lg text-sm" />
|
||||
<button type="button" @click="removeOption(i)" class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded">
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="localData.rules.required" @change="emitUpdate" type="checkbox" class="h-4 w-4 rounded border-layer-line text-primary focus:ring-primary" />
|
||||
<label class="text-sm text-foreground">Обязательное поле</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../../../DashboardIcon.vue';
|
||||
export default {
|
||||
name: 'FormFieldSingleChoice',
|
||||
components: { DashboardIcon },
|
||||
props: { modelValue: Object },
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { localData: { columns: [], rules: { required: false }, ...this.modelValue } }
|
||||
},
|
||||
methods: {
|
||||
emitUpdate() { this.$emit('update:modelValue', { ...this.localData }) },
|
||||
addOption() {
|
||||
if (!this.localData.columns) this.localData.columns = [];
|
||||
this.localData.columns.push({ title_field: '', name_field: '' });
|
||||
this.emitUpdate();
|
||||
},
|
||||
removeOption(i) { this.localData.columns.splice(i, 1); this.emitUpdate(); }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название поля <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="localData.title_field"
|
||||
type="text"
|
||||
@input="emitUpdate"
|
||||
placeholder="Например: Ваше имя"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Подсказка
|
||||
</label>
|
||||
<textarea
|
||||
v-model="localData.description"
|
||||
@input="emitUpdate"
|
||||
rows="2"
|
||||
placeholder="Подсказка для пользователя"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg text-sm resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="localData.rules.required"
|
||||
@change="emitUpdate"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-layer-line text-primary focus:ring-primary"
|
||||
/>
|
||||
<label class="text-sm text-foreground">Обязательное поле</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormFieldText',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({}) }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
localData: { ...this.modelValue }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emitUpdate() {
|
||||
this.$emit('update:modelValue', { ...this.localData });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldTextarea', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldUrl', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border border-layer-line rounded-lg">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ label || 'Контент' }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = true"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Добавить блок
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blocks List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(block, index) in blocks"
|
||||
:key="block._uid"
|
||||
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Block Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<!-- Drag Handle -->
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-move text-muted-foreground-1 hover:text-foreground transition-colors"
|
||||
@mousedown="startDrag(index, $event)"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Block Icon & Label -->
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<component :is="getBlockIcon(block.type)" class="w-4 h-4 text-muted-foreground-1" />
|
||||
<span class="text-sm font-medium text-foreground">{{ getBlockLabel(block.type) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Клонировать"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
:title="collapsedBlocks.includes(index) ? 'Развернуть' : 'Свернуть'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-180': !collapsedBlocks.includes(index) }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Content -->
|
||||
<div v-show="!collapsedBlocks.includes(index)" class="p-4">
|
||||
<component
|
||||
:is="getBlockComponent(block.type)"
|
||||
v-model="block.data"
|
||||
:all-blocks="blocks"
|
||||
@update="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="blocks.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 border-2 border-dashed border-layer-line rounded-lg bg-muted/20"
|
||||
>
|
||||
<svg class="w-12 h-12 text-muted-foreground-1 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-foreground mb-1">Нет блоков контента</p>
|
||||
<p class="text-xs text-muted-foreground-1 text-center">Нажмите "Добавить блок" чтобы начать</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Picker Modal -->
|
||||
<div
|
||||
v-if="showBlockPicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showBlockPicker = false"
|
||||
>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-foreground">Выберите тип блока</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = false"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded transition-all"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="blockType in availableBlocks"
|
||||
:key="blockType.type"
|
||||
type="button"
|
||||
@click="addBlock(blockType.type)"
|
||||
class="flex flex-col items-center gap-2 p-4 border border-layer-line rounded-lg hover:border-primary hover:bg-primary/5 transition-all group"
|
||||
>
|
||||
<component :is="getBlockIcon(blockType.type)" class="w-8 h-8 text-muted-foreground-1 group-hover:text-primary transition-colors" />
|
||||
<span class="text-xs font-medium text-foreground text-center group-hover:text-primary transition-colors">
|
||||
{{ getBlockLabel(blockType.type) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
|
||||
// Block Components (БЕЗ TabBlock — предотвращаем циклическую зависимость)
|
||||
import HeadingBlock from './blocks/HeadingBlock.vue';
|
||||
import ParagraphBlock from './blocks/ParagraphBlock.vue';
|
||||
import ImageBlock from './blocks/ImageBlock.vue';
|
||||
import ImagesBlock from './blocks/ImagesBlock.vue';
|
||||
import FilesBlock from './blocks/FilesBlock.vue';
|
||||
import FastFilesBlock from './blocks/FastFilesBlock.vue';
|
||||
import VideoBlock from './blocks/VideoBlock.vue';
|
||||
import PersonBlock from './blocks/PersonBlock.vue';
|
||||
import StepperBlock from './blocks/StepperBlock.vue';
|
||||
import SliderBlock from './blocks/SliderBlock.vue';
|
||||
import PostItemBlock from './blocks/PostItemBlock.vue';
|
||||
import PostListBlock from './blocks/PostListBlock.vue';
|
||||
import PageItemBlock from './blocks/PageItemBlock.vue';
|
||||
import PageResourceListBlock from './blocks/PageResourceListBlock.vue';
|
||||
import ContactBlock from './blocks/ContactBlock.vue';
|
||||
import CustomFormBlock from './blocks/CustomFormBlock.vue';
|
||||
|
||||
// Icons
|
||||
import IconHeading from './icons/IconHeading.vue';
|
||||
import IconParagraph from './icons/IconParagraph.vue';
|
||||
import IconImage from './icons/IconImage.vue';
|
||||
import IconImages from './icons/IconImages.vue';
|
||||
import IconFiles from './icons/IconFiles.vue';
|
||||
import IconVideo from './icons/IconVideo.vue';
|
||||
import IconPerson from './icons/IconPerson.vue';
|
||||
import IconStepper from './icons/IconStepper.vue';
|
||||
import IconSlider from './icons/IconSlider.vue';
|
||||
import IconPost from './icons/IconPost.vue';
|
||||
import IconPage from './icons/IconPage.vue';
|
||||
import IconContact from './icons/IconContact.vue';
|
||||
import IconForm from './icons/IconForm.vue';
|
||||
import IconArchive from './icons/IconArchive.vue';
|
||||
|
||||
export default {
|
||||
name: 'TabContentBuilder',
|
||||
components: {
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
ImageBlock,
|
||||
ImagesBlock,
|
||||
FilesBlock,
|
||||
FastFilesBlock,
|
||||
VideoBlock,
|
||||
PersonBlock,
|
||||
StepperBlock,
|
||||
// TabBlock намеренно исключён — предотвращаем циклическую зависимость
|
||||
SliderBlock,
|
||||
PostItemBlock,
|
||||
PostListBlock,
|
||||
PageItemBlock,
|
||||
PageResourceListBlock,
|
||||
ContactBlock,
|
||||
CustomFormBlock,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const blocks = ref([]);
|
||||
const collapsedBlocks = ref([]);
|
||||
const showBlockPicker = ref(false);
|
||||
let uidCounter = 0;
|
||||
|
||||
// Доступные блоки (БЕЗ 'tabs')
|
||||
const availableBlocks = [
|
||||
{ type: 'heading' },
|
||||
{ type: 'paragraph' },
|
||||
{ type: 'image' },
|
||||
{ type: 'images' },
|
||||
{ type: 'files' },
|
||||
{ type: 'fast_files' },
|
||||
{ type: 'video' },
|
||||
{ type: 'person' },
|
||||
{ type: 'stepper' },
|
||||
// { type: 'tabs' }, // Исключён — предотвращаем циклическую зависимость
|
||||
{ type: 'slider' },
|
||||
{ type: 'postItem' },
|
||||
{ type: 'postsList' },
|
||||
{ type: 'pageItem' },
|
||||
{ type: 'pageResourceList' },
|
||||
{ type: 'contact' },
|
||||
{ type: 'customForm' },
|
||||
];
|
||||
|
||||
const blockLabels = {
|
||||
heading: 'Заголовок',
|
||||
paragraph: 'Текст',
|
||||
image: 'Изображение',
|
||||
images: 'Слайдер изображений',
|
||||
files: 'Файлы',
|
||||
fast_files: 'Быстрая загрузка файлов',
|
||||
video: 'Видео',
|
||||
person: 'Персона',
|
||||
stepper: 'Этапы',
|
||||
slider: 'Слайдер',
|
||||
postItem: 'Конкретная новость',
|
||||
postsList: 'Список новостей',
|
||||
pageItem: 'Конкретная страница',
|
||||
pageResourceList: 'Ресурсы',
|
||||
contact: 'Контакты',
|
||||
customForm: 'Пользовательская форма',
|
||||
};
|
||||
|
||||
const blockIcons = {
|
||||
heading: IconHeading,
|
||||
paragraph: IconParagraph,
|
||||
image: IconImage,
|
||||
images: IconImages,
|
||||
files: IconFiles,
|
||||
fast_files: IconFiles,
|
||||
video: IconVideo,
|
||||
person: IconPerson,
|
||||
stepper: IconStepper,
|
||||
slider: IconSlider,
|
||||
postItem: IconPost,
|
||||
postsList: IconPost,
|
||||
pageItem: IconPage,
|
||||
pageResourceList: IconArchive,
|
||||
contact: IconContact,
|
||||
customForm: IconForm,
|
||||
};
|
||||
|
||||
const blockDefaults = {
|
||||
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
||||
paragraph: () => ({ seo_active: true, content: '' }),
|
||||
image: () => ({ url: '', alt: '' }),
|
||||
images: () => ({ url: [], alt: '' }),
|
||||
files: () => ({ file: [] }),
|
||||
fast_files: () => ({ path: [] }),
|
||||
video: () => ({ mime: '', title: '', path: '' }),
|
||||
person: () => ({ name: '', photo: '', info: [{ column: '', content: '' }] }),
|
||||
stepper: () => ({ step_name: '', steps: [{ title: '', content: '' }] }),
|
||||
slider: () => ({ slider: '' }),
|
||||
postItem: () => ({ post: null }),
|
||||
postsList: () => ({ count: 5, category: null }),
|
||||
pageItem: () => ({ page: null }),
|
||||
pageResourceList: () => ({ resource: '' }),
|
||||
contact: () => ({ contact: '' }),
|
||||
customForm: () => ({ form: '', settings: { in_modal: false } }),
|
||||
};
|
||||
|
||||
function generateUid() {
|
||||
return `block-${Date.now()}-${uidCounter++}`;
|
||||
}
|
||||
|
||||
function addBlock(type) {
|
||||
blocks.value.push({
|
||||
_uid: generateUid(),
|
||||
type,
|
||||
data: (blockDefaults[type] || blockDefaults.paragraph)()
|
||||
});
|
||||
showBlockPicker.value = false;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeBlock(index) {
|
||||
blocks.value.splice(index, 1);
|
||||
collapsedBlocks.value = collapsedBlocks.value.filter(i => i !== index);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function duplicateBlock(index) {
|
||||
const original = blocks.value[index];
|
||||
blocks.value.splice(index + 1, 0, {
|
||||
_uid: generateUid(),
|
||||
type: original.type,
|
||||
data: JSON.parse(JSON.stringify(original.data))
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function toggleBlock(index) {
|
||||
const idx = collapsedBlocks.value.indexOf(index);
|
||||
if (idx === -1) {
|
||||
collapsedBlocks.value.push(index);
|
||||
} else {
|
||||
collapsedBlocks.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockComponent(type) {
|
||||
const componentMap = {
|
||||
heading: 'HeadingBlock',
|
||||
paragraph: 'ParagraphBlock',
|
||||
image: 'ImageBlock',
|
||||
images: 'ImagesBlock',
|
||||
files: 'FilesBlock',
|
||||
fast_files: 'FastFilesBlock',
|
||||
video: 'VideoBlock',
|
||||
person: 'PersonBlock',
|
||||
stepper: 'StepperBlock',
|
||||
slider: 'SliderBlock',
|
||||
postItem: 'PostItemBlock',
|
||||
postsList: 'PostListBlock',
|
||||
pageItem: 'PageItemBlock',
|
||||
pageResourceList: 'PageResourceListBlock',
|
||||
contact: 'ContactBlock',
|
||||
customForm: 'CustomFormBlock',
|
||||
};
|
||||
return componentMap[type] || 'ParagraphBlock';
|
||||
}
|
||||
|
||||
function getBlockIcon(type) {
|
||||
return blockIcons[type] || IconParagraph;
|
||||
}
|
||||
|
||||
function getBlockLabel(type) {
|
||||
return blockLabels[type] || type;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const output = blocks.value.map(({ _uid, ...block }) => block);
|
||||
emit('update:modelValue', output);
|
||||
}
|
||||
|
||||
// Drag and Drop
|
||||
let dragIndex = null;
|
||||
|
||||
function startDrag(index, event) {
|
||||
dragIndex = index;
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
// Simplified drag logic - can be enhanced with a library like vuedraggable
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragIndex = null;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Initialize from modelValue
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
blocks.value = props.modelValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for modelValue changes (e.g., when loading existing content)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0 && blocks.value.length === 0) {
|
||||
// Only initialize if blocks are empty (initial load from server)
|
||||
blocks.value = newValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
collapsedBlocks,
|
||||
showBlockPicker,
|
||||
availableBlocks,
|
||||
addBlock,
|
||||
removeBlock,
|
||||
duplicateBlock,
|
||||
toggleBlock,
|
||||
getBlockComponent,
|
||||
getBlockIcon,
|
||||
getBlockLabel,
|
||||
emitChange,
|
||||
startDrag,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Контактный виджет <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.contact"
|
||||
@change="update('contact', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите контакт...</option>
|
||||
<option v-for="contact in contacts" :key="contact.slug" :value="contact.slug">
|
||||
{{ contact.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ContactBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { contacts: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/contact-widgets/active');
|
||||
this.contacts = await response.json();
|
||||
} catch (e) { console.error('Failed to load contacts:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Форма <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.form"
|
||||
@change="update('form', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите форму...</option>
|
||||
<option v-for="form in forms" :key="form.form_id" :value="form.form_id">
|
||||
{{ form.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.settings.in_modal"
|
||||
@change="updateSettings('in_modal', $event.target.checked)"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="text-sm text-foreground">
|
||||
Открывать в модальном окне
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CustomFormBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { forms: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/custom-forms/published');
|
||||
this.forms = await response.json();
|
||||
} catch (e) { console.error('Failed to load forms:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateSettings(field, value) {
|
||||
this.update('settings', { ...this.modelValue.settings, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(path, index) in modelValue.path"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 p-3 border border-layer-line rounded-lg"
|
||||
>
|
||||
<div class="flex-1 text-sm text-foreground truncate">
|
||||
{{ path.split('/').pop() || path }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFile(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFilesUpload"
|
||||
accept=".pdf,.docx,.xlsx,.pptx,.zip"
|
||||
multiple
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">
|
||||
PDF, DOCX, XLSX, PPTX, ZIP
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FastFilesBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
removeFile(index) {
|
||||
this.update('path', this.modelValue.path.filter((_, i) => i !== index));
|
||||
},
|
||||
handleFilesUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
const newPaths = [...this.modelValue.path];
|
||||
files.forEach(file => {
|
||||
newPaths.push(URL.createObjectURL(file));
|
||||
});
|
||||
this.update('path', newPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(file, index) in modelValue.file"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 p-3 border border-layer-line rounded-lg"
|
||||
>
|
||||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
:value="file.title"
|
||||
@input="updateFile(index, 'title', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="Название файла"
|
||||
class="px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<div class="text-xs text-muted-foreground-1 flex items-center">
|
||||
<span>{{ file.expansion?.toUpperCase() || '—' }}</span>
|
||||
<span class="mx-1">•</span>
|
||||
<span>{{ file.size || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFile(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFilesUpload"
|
||||
accept=".pdf,.docx,.xlsx,.pptx,.zip"
|
||||
multiple
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">
|
||||
PDF, DOCX, XLSX, PPTX, ZIP. Макс. 512MB
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addFile"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить файл
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FilesBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateFile(index, field, value) {
|
||||
const files = [...this.modelValue.file];
|
||||
files[index] = { ...files[index], [field]: value };
|
||||
this.update('file', files);
|
||||
},
|
||||
addFile() {
|
||||
this.update('file', [...this.modelValue.file, { title: '', path: '', expansion: '', size: '', time_added: Date.now() }]);
|
||||
},
|
||||
removeFile(index) {
|
||||
this.update('file', this.modelValue.file.filter((_, i) => i !== index));
|
||||
},
|
||||
handleFilesUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
files.forEach(file => {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
const size = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
this.update('file', [...this.modelValue.file, {
|
||||
title: file.name,
|
||||
path: URL.createObjectURL(file),
|
||||
expansion: extension,
|
||||
size: size,
|
||||
time_added: Date.now()
|
||||
}]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Заголовок <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.content"
|
||||
@input="update('content', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Введите заголовок раздела"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Якорь (ID)
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.id"
|
||||
@input="update('id', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="anchor-link-..."
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-muted text-muted-foreground-1 text-sm"
|
||||
readonly
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Генерируется автоматически</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HeadingBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Изображение <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="modelValue.url" class="relative">
|
||||
<img :src="modelValue.url" alt="Preview" class="w-32 h-32 object-cover rounded-lg border border-layer-line" />
|
||||
<button
|
||||
type="button"
|
||||
@click="update('url', '')"
|
||||
class="absolute -top-2 -right-2 p-1 bg-danger text-white rounded-full hover:bg-danger-hover transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
accept="image/*"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Загрузите изображение (JPG, PNG, WebP)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Alt текст
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.alt"
|
||||
@input="update('alt', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="Описание изображения"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ImageBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.update('url', e.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Изображения <span class="text-danger">*</span> (макс. 5)
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-3 mb-3">
|
||||
<div
|
||||
v-for="(url, index) in modelValue.url"
|
||||
:key="index"
|
||||
class="relative group"
|
||||
>
|
||||
<img :src="url" alt="Preview" class="w-full h-24 object-cover rounded-lg border border-layer-line" />
|
||||
<button
|
||||
type="button"
|
||||
@click="removeImage(index)"
|
||||
class="absolute -top-2 -right-2 p-1 bg-danger text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFilesUpload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:disabled="modelValue.url.length >= 5"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Можно загрузить до 5 изображений (JPG, PNG, WebP)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Alt текст
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.alt"
|
||||
@input="update('alt', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="Описание изображений"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ImagesBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
},
|
||||
handleFilesUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
const remaining = 5 - this.modelValue.url.length;
|
||||
const toProcess = files.slice(0, remaining);
|
||||
|
||||
let processed = 0;
|
||||
toProcess.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const newUrls = [...this.modelValue.url, e.target.result];
|
||||
this.update('url', newUrls);
|
||||
processed++;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
removeImage(index) {
|
||||
const newUrls = this.modelValue.url.filter((_, i) => i !== index);
|
||||
this.update('url', newUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Страница <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.page"
|
||||
@change="update('page', parseInt($event.target.value))"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option :value="null">Выберите страницу...</option>
|
||||
<option v-for="page in pages" :key="page.id" :value="page.id">
|
||||
{{ page.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PageItemBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { pages: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/pages/visible');
|
||||
this.pages = await response.json();
|
||||
} catch (e) { console.error('Failed to load pages:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Список ресурсов <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.resource"
|
||||
@change="update('resource', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите ресурс...</option>
|
||||
<option v-for="resource in resources" :key="resource.slug" :value="resource.slug">
|
||||
{{ resource.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PageResourceListBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { resources: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/page-reference-lists/active');
|
||||
this.resources = await response.json();
|
||||
} catch (e) { console.error('Failed to load resources:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.seo_active"
|
||||
@change="update('seo_active', $event.target.checked)"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="text-sm text-foreground">
|
||||
Активировать SEO для этого блока
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Текст <span class="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
:id="editorId"
|
||||
:value="modelValue.content"
|
||||
rows="8"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
|
||||
></textarea>
|
||||
<p v-if="loading" class="mt-1 text-xs text-primary">
|
||||
Загрузка редактора...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
let editorCounter = 0;
|
||||
|
||||
export default {
|
||||
name: 'ParagraphBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
editorId: `paragraph-editor-${++editorCounter}`,
|
||||
editor: null,
|
||||
loading: true
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.waitForTinyMCE();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyEditor();
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
},
|
||||
waitForTinyMCE() {
|
||||
if (typeof tinymce !== 'undefined') {
|
||||
this.initEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ждем загрузки TinyMCE с таймаутом
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 секунд
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (typeof tinymce !== 'undefined') {
|
||||
clearInterval(interval);
|
||||
this.initEditor();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
this.loading = false;
|
||||
console.error('TinyMCE не загрузился в течение 5 секунд');
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
initEditor() {
|
||||
this.loading = true;
|
||||
|
||||
tinymce.init({
|
||||
selector: `#${this.editorId}`,
|
||||
license_key: 'gpl',
|
||||
autoresize_bottom_margin: 20,
|
||||
autoresize_overflow_padding: 20,
|
||||
max_height: 600,
|
||||
menubar: false,
|
||||
statusbar: true,
|
||||
branding: false,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount',
|
||||
'autoresize'
|
||||
],
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic forecolor | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
||||
'removeformat | help',
|
||||
content_style: 'body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; }',
|
||||
setup: (editor) => {
|
||||
this.editor = editor;
|
||||
editor.on('init', () => {
|
||||
editor.setContent(this.modelValue.content || '');
|
||||
this.loading = false;
|
||||
});
|
||||
editor.on('change', () => {
|
||||
this.update('content', editor.getContent());
|
||||
});
|
||||
editor.on('Remove', () => {
|
||||
this.editor = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
destroyEditor() {
|
||||
if (this.editor && typeof tinymce !== 'undefined') {
|
||||
tinymce.remove(this.editor);
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
ФИО <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.name"
|
||||
@input="update('name', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Фото
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
@change="handlePhotoUpload"
|
||||
accept="image/*"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Информация <span class="text-danger">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addInfoRow"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(info, index) in modelValue.info"
|
||||
:key="index"
|
||||
class="grid grid-cols-2 gap-2 p-3 border border-layer-line rounded-lg"
|
||||
>
|
||||
<input
|
||||
:value="info.column"
|
||||
@input="updateInfo(index, 'column', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Должность"
|
||||
class="px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
:value="info.content"
|
||||
@input="updateInfo(index, 'content', $event.target.value)"
|
||||
required
|
||||
maxlength="1000"
|
||||
placeholder="Описание"
|
||||
rows="2"
|
||||
class="flex-1 px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeInfo(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all self-center"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PersonBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateInfo(index, field, value) {
|
||||
const info = [...this.modelValue.info];
|
||||
info[index] = { ...info[index], [field]: value };
|
||||
this.update('info', info);
|
||||
},
|
||||
addInfoRow() {
|
||||
this.update('info', [...this.modelValue.info, { column: '', content: '' }]);
|
||||
},
|
||||
removeInfo(index) {
|
||||
if (this.modelValue.info.length > 1) {
|
||||
this.update('info', this.modelValue.info.filter((_, i) => i !== index));
|
||||
}
|
||||
},
|
||||
handlePhotoUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => this.update('photo', e.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Новость <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.post"
|
||||
@change="update('post', parseInt($event.target.value))"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option :value="null">Выберите новость...</option>
|
||||
<option v-for="post in posts" :key="post.id" :value="post.id">
|
||||
{{ post.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PostItemBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { posts: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/posts/published?limit=100');
|
||||
this.posts = await response.json();
|
||||
} catch (e) { console.error('Failed to load posts:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Количество
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.count"
|
||||
@input="update('count', parseInt($event.target.value) || 5)"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Категория
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.category"
|
||||
@change="update('category', $event.target.value ? parseInt($event.target.value) : null)"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option :value="null">Все категории</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PostListBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { categories: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/categories');
|
||||
this.categories = await response.json();
|
||||
} catch (e) { console.error('Failed to load categories:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Слайдер <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.slider"
|
||||
@change="update('slider', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите слайдер...</option>
|
||||
<option v-for="slider in sliders" :key="slider.slug" :value="slider.slug">
|
||||
{{ slider.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Только активные слайдеры с изображениями
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SliderBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { sliders: [] };
|
||||
},
|
||||
async mounted() {
|
||||
// Загрузка слайдеров с бэкенда
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/sliders/active');
|
||||
this.sliders = await response.json();
|
||||
} catch (e) {
|
||||
console.error('Failed to load sliders:', e);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название процесса <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.step_name"
|
||||
@input="update('step_name', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Процесс оформления"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Шаги <span class="text-danger">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addStep"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить шаг
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(step, index) in modelValue.steps"
|
||||
:key="index"
|
||||
class="p-4 border border-layer-line rounded-lg space-y-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<input
|
||||
:value="step.title"
|
||||
@input="updateStep(index, 'title', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Название шага"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeStep(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
:disabled="modelValue.steps.length <= 1"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
:id="`step-content-${index}-${_uid}`"
|
||||
:value="step.content"
|
||||
@input="updateStep(index, 'content', $event.target.value)"
|
||||
required
|
||||
rows="3"
|
||||
placeholder="Описание шага"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'StepperBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateStep(index, field, value) {
|
||||
const steps = [...this.modelValue.steps];
|
||||
steps[index] = { ...steps[index], [field]: value };
|
||||
this.update('steps', steps);
|
||||
},
|
||||
addStep() {
|
||||
this.update('steps', [...this.modelValue.steps, { title: '', content: '' }]);
|
||||
},
|
||||
removeStep(index) {
|
||||
if (this.modelValue.steps.length > 1) {
|
||||
this.update('steps', this.modelValue.steps.filter((_, i) => i !== index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Settings -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.settings.is_accordion"
|
||||
@change="updateSettings('is_accordion', $event.target.checked)"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="text-sm text-foreground">
|
||||
Режим аккордеона (только одна вкладка открыта)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Вкладки <span class="text-danger">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addTab"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить вкладку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(tab, tabIndex) in tabs"
|
||||
:key="tab._uid"
|
||||
class="border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Tab Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<input
|
||||
:value="tab.title"
|
||||
@input="updateTab(tabIndex, 'title', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Название вкладки"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeTab(tabIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
:disabled="tabs.length <= 1"
|
||||
title="Удалить вкладку"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content (Nested TabContentBuilder) -->
|
||||
<div class="p-4">
|
||||
<TabContentBuilder
|
||||
:model-value="tab.content"
|
||||
@update:model-value="updateTab(tabIndex, 'content', $event)"
|
||||
label="Содержимое вкладки"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TabContentBuilder from '../TabContentBuilder.vue';
|
||||
|
||||
export default {
|
||||
name: 'TabBlock',
|
||||
components: {
|
||||
TabContentBuilder
|
||||
},
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
tabs() {
|
||||
const rawTabs = this.modelValue?.tab || [];
|
||||
// Нормализуем _uid для всех вкладок (если пришли с сервера без UID)
|
||||
return rawTabs.map(tab => ({
|
||||
...tab,
|
||||
_uid: tab._uid || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateSettings(field, value) {
|
||||
this.update('settings', { ...this.modelValue.settings, [field]: value });
|
||||
},
|
||||
updateTab(index, field, value) {
|
||||
const tabs = [...this.tabs];
|
||||
tabs[index] = { ...tabs[index], [field]: value };
|
||||
this.update('tab', tabs);
|
||||
},
|
||||
addTab() {
|
||||
const newTab = {
|
||||
_uid: `tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: '',
|
||||
content: []
|
||||
};
|
||||
this.update('tab', [...this.tabs, newTab]);
|
||||
},
|
||||
removeTab(index) {
|
||||
if (this.tabs.length > 1) {
|
||||
this.update('tab', this.tabs.filter((_, i) => i !== index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Видеофайл <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div v-if="modelValue.path" class="mb-3">
|
||||
<video :src="modelValue.path" controls class="w-full max-w-md rounded-lg border border-layer-line"></video>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
accept="video/*"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
mp4, mov, avi, webm, ogg
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название видео <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.title"
|
||||
@input="update('title', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Введите название видео"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VideoBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.update('mime', file.type);
|
||||
this.update('path', URL.createObjectURL(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3v4a1 1 0 001 1h4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 8h2m8 8h2m-2-8v2m-8 8v2" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<BasicIcon
|
||||
v-if="iconName"
|
||||
:name="iconName"
|
||||
:class="iconClass"
|
||||
:color="color"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BasicIcon from '../../../componentss/ui/icons/BasicIcon.vue';
|
||||
|
||||
/**
|
||||
* DashboardIcon - стандартизированный компонент иконок для Dashboard
|
||||
*
|
||||
* Использует SVG sprite систему через BasicIcon.
|
||||
* Все Heroicons доступны из папки resources/js/assets/icons/svg/
|
||||
*
|
||||
* @example
|
||||
* // Базовое использование
|
||||
* <DashboardIcon name="building-office" />
|
||||
*
|
||||
* @example
|
||||
* // С размером и цветом
|
||||
* <DashboardIcon name="plus" size="5" color="text-primary" />
|
||||
*
|
||||
* @example
|
||||
* // С кастомными классами
|
||||
* <DashboardIcon name="trash" size="4" class="hover:text-danger" />
|
||||
*/
|
||||
export default {
|
||||
name: 'DashboardIcon',
|
||||
components: {
|
||||
BasicIcon,
|
||||
},
|
||||
props: {
|
||||
/**
|
||||
* Имя иконки (без префикса heroicon-)
|
||||
* Примеры: 'building-office', 'plus', 'trash', 'pencil-square'
|
||||
*/
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* Размер иконки (в единицах Tailwind: 3, 4, 5, 6 и т.д.)
|
||||
* @default '5'
|
||||
*/
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: '5',
|
||||
},
|
||||
/**
|
||||
* Цвет иконки (Tailwind класс)
|
||||
* @default 'currentColor'
|
||||
*/
|
||||
color: {
|
||||
type: String,
|
||||
default: 'currentColor',
|
||||
},
|
||||
/**
|
||||
* Дополнительные CSS классы
|
||||
*/
|
||||
class: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconName() {
|
||||
if (!this.name) return '';
|
||||
|
||||
// Добавляем префикс heroicon-o- (outline) по умолчанию
|
||||
// Если имя уже содержит префикс, используем как есть
|
||||
if (this.name.startsWith('heroicon-')) {
|
||||
return this.name;
|
||||
}
|
||||
return `heroicon-o-${this.name}`;
|
||||
},
|
||||
iconClass() {
|
||||
const sizeMap = {
|
||||
'3': 'w-3 h-3',
|
||||
'4': 'w-4 h-4',
|
||||
'5': 'w-5 h-5',
|
||||
'6': 'w-6 h-6',
|
||||
'7': 'w-7 h-7',
|
||||
'8': 'w-8 h-8',
|
||||
'10': 'w-10 h-10',
|
||||
'12': 'w-12 h-12',
|
||||
};
|
||||
const sizeClass = sizeMap[String(this.size)] || 'w-5 h-5';
|
||||
return `${sizeClass} ${this.class}`.trim();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<!-- Sidebar -->
|
||||
<DashboardSidebar :is-open="sidebarOpen" @close="closeSidebar" />
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
@click="openSidebar"
|
||||
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-layer border border-layer-line shadow-sm hover:bg-primary-50 transition-all"
|
||||
aria-label="Открыть меню"
|
||||
>
|
||||
<DashboardIcon name="bars-3" size="5" class="text-foreground" />
|
||||
</button>
|
||||
|
||||
<!-- Main Content (with sidebar offset on desktop) -->
|
||||
<div class="lg:pl-64">
|
||||
<!-- Header -->
|
||||
<header class="bg-layer/95 backdrop-blur-md border-b border-layer-line sticky top-0 z-20 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<slot name="header-icon">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
</slot>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-sm font-medium text-foreground leading-tight">
|
||||
<slot name="header-title">Панель управления</slot>
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground-1 leading-tight">
|
||||
<slot name="header-subtitle"></slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<slot name="header-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Breadcrumbs -->
|
||||
<slot name="breadcrumbs"></slot>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DashboardSidebar from './DashboardSidebar.vue';
|
||||
import DashboardIcon from './DashboardIcon.vue';
|
||||
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
const openSidebar = () => {
|
||||
sidebarOpen.value = true;
|
||||
};
|
||||
|
||||
const closeSidebar = () => {
|
||||
sidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Desktop Sidebar (always visible on lg+) -->
|
||||
<aside class="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-layer border-r border-layer-line z-30">
|
||||
<div class="flex flex-col h-full">
|
||||
<SidebarLogo />
|
||||
<SidebarNav :mobile="false" @child-click="noop" />
|
||||
<SidebarUser />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Sidebar Overlay -->
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
@click="closeSidebar"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</transition>
|
||||
|
||||
<!-- Mobile Sidebar -->
|
||||
<transition
|
||||
enter-active-class="transition-transform duration-200 ease-out"
|
||||
enter-from-class="-translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-150 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-y-0 left-0 w-72 bg-layer border-r border-layer-line z-50 flex flex-col lg:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Навигация"
|
||||
@keydown.escape="closeSidebar"
|
||||
>
|
||||
<!-- Mobile Header -->
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-layer-line">
|
||||
<SidebarLogo />
|
||||
<button
|
||||
@click="closeSidebar"
|
||||
class="p-2 rounded-lg hover:bg-muted-hover transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
aria-label="Закрыть меню"
|
||||
>
|
||||
<DashboardIcon name="x-mark" size="5" class="text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav ref="mobileNavRef" class="flex-1 overflow-y-auto focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" tabindex="-1">
|
||||
<SidebarNav :mobile="true" @child-click="closeSidebar" />
|
||||
</nav>
|
||||
<SidebarUser />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue';
|
||||
import SidebarLogo from './SidebarLogo.vue';
|
||||
import SidebarNav from './SidebarNav.vue';
|
||||
import SidebarUser from './SidebarUser.vue';
|
||||
import DashboardIcon from './DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const mobileNavRef = ref(null);
|
||||
const previousActiveElement = ref(null);
|
||||
|
||||
const closeSidebar = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Блокировка скролла и управление фокусом
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
// Сохраняем предыдущий активный элемент
|
||||
previousActiveElement.value = document.activeElement;
|
||||
// Блокируем скролл body
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Фокус на первый интерактивный элемент внутри модалки
|
||||
await nextTick();
|
||||
const focusableElement = mobileNavRef.value?.querySelector('a, button');
|
||||
if (focusableElement) {
|
||||
focusableElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Восстанавливаем скролл
|
||||
document.body.style.overflow = '';
|
||||
// Возвращаем фокус на предыдущий элемент
|
||||
if (previousActiveElement.value && document.body.contains(previousActiveElement.value)) {
|
||||
previousActiveElement.value.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup при unmount — предотвращаем утечку scroll lock
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 h-16 border-b border-layer-line">
|
||||
<div class="w-9 h-9 bg-primary/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<DashboardIcon name="document-text" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-foreground">NTSPI</span>
|
||||
<span class="text-xs text-muted-foreground-1">Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DashboardIcon from './DashboardIcon.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<div class="space-y-1">
|
||||
<SidebarNavItem
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:mobile="mobile"
|
||||
:is-expanded="expandedKey === item.key"
|
||||
@child-click="$emit('child-click')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-4 border-t border-layer-line"></div>
|
||||
|
||||
<!-- Quick Actions Section -->
|
||||
<div>
|
||||
<h3 class="px-3 mb-2 text-xs font-semibold text-muted-foreground-1 uppercase tracking-wide">
|
||||
Быстрые действия
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
<a
|
||||
v-for="action in quickActions"
|
||||
:key="action.label"
|
||||
:href="action.external ? action.href : route(action.route)"
|
||||
:target="action.external ? '_blank' : null"
|
||||
:rel="action.external ? 'noopener noreferrer' : null"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-foreground hover:bg-primary-50 hover:text-primary transition-all duration-200 group"
|
||||
>
|
||||
<component :is="iconMap[action.icon]" class="w-5 h-5 flex-shrink-0" />
|
||||
<span>{{ action.label }}</span>
|
||||
<DashboardIcon
|
||||
v-if="action.external"
|
||||
name="arrow-up-right"
|
||||
size="4"
|
||||
class="ml-auto text-muted-foreground-2 group-hover:text-primary"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { menuItems } from './menuConfig';
|
||||
import { quickActions } from './quickActionsConfig';
|
||||
import SidebarNavItem from './SidebarNavItem.vue';
|
||||
import DashboardIcon from './DashboardIcon.vue';
|
||||
|
||||
defineProps({
|
||||
mobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['child-click']);
|
||||
|
||||
const iconMap = {
|
||||
cog: DashboardIcon,
|
||||
};
|
||||
|
||||
// Открываем только аккордеон активной страницы
|
||||
const expandedKey = computed(() => {
|
||||
const currentRoute = route().current();
|
||||
|
||||
for (const item of menuItems) {
|
||||
if (item.activePrefixes && item.activePrefixes.some(prefix => currentRoute.startsWith(prefix))) {
|
||||
return item.key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<!-- Simple link (no children) -->
|
||||
<Link
|
||||
v-if="!item.children"
|
||||
:href="route(item.route)"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group"
|
||||
:class="linkClass"
|
||||
:aria-current="isActiveItem ? 'page' : null"
|
||||
>
|
||||
<DashboardIcon :name="iconComponent" size="5" class="flex-shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</Link>
|
||||
|
||||
<!-- Collapsible section -->
|
||||
<div v-else class="space-y-1">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group"
|
||||
:class="linkClass"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-controls="`sidebar-section-${item.key}`"
|
||||
>
|
||||
<DashboardIcon :name="iconComponent" size="5" class="flex-shrink-0" />
|
||||
<span class="flex-1 text-left">{{ item.label }}</span>
|
||||
<DashboardIcon
|
||||
name="chevron-down"
|
||||
size="4"
|
||||
class="transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-show="isExpanded" :id="`sidebar-section-${item.key}`" class="ml-8 space-y-1">
|
||||
<Link
|
||||
v-for="child in item.children"
|
||||
:key="child.route"
|
||||
:href="route(child.route)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all duration-200"
|
||||
:class="getChildClass(child.route)"
|
||||
@click="onChildClick"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-current" aria-hidden="true"></span>
|
||||
{{ child.label }}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import DashboardIcon from './DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
mobile: { type: Boolean, default: false },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['child-click', 'toggle']);
|
||||
|
||||
const iconMap = {
|
||||
home: 'home',
|
||||
document: 'document-text',
|
||||
book: 'book-open',
|
||||
calendar: 'calendar',
|
||||
image: 'photo',
|
||||
building: 'building-office',
|
||||
cog: 'cog-6-tooth',
|
||||
upload: 'document-text',
|
||||
folder: 'folder',
|
||||
beaker: 'beaker',
|
||||
'user-circle': 'user-circle',
|
||||
'rectangle-stack': 'rectangle-stack',
|
||||
};
|
||||
|
||||
const iconComponent = computed(() => iconMap[props.item.icon] || 'home');
|
||||
|
||||
const isActiveItem = computed(() => {
|
||||
if (!props.item.route) return false;
|
||||
return route().current(props.item.route);
|
||||
});
|
||||
|
||||
const isSectionActive = computed(() => {
|
||||
if (!props.item.activePrefixes) return false;
|
||||
return props.item.activePrefixes.some((prefix) => route().current(prefix + '*'));
|
||||
});
|
||||
|
||||
// Локальное состояние для ручного переключения
|
||||
const localExpanded = ref(props.isExpanded);
|
||||
|
||||
// При изменении пропса обновляем локальное состояние
|
||||
watch(() => props.isExpanded, (val) => {
|
||||
localExpanded.value = val;
|
||||
});
|
||||
|
||||
// При смене роута сбрасываем на значение пропса (только активный аккордеон)
|
||||
watch(() => route().current(), () => {
|
||||
localExpanded.value = props.isExpanded;
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
localExpanded.value = !localExpanded.value;
|
||||
};
|
||||
|
||||
// Используем локальное состояние в template
|
||||
const isExpanded = computed(() => localExpanded.value);
|
||||
|
||||
const linkClass = computed(() => {
|
||||
const active = isActiveItem.value || isSectionActive.value;
|
||||
const hasChildren = !!props.item.children;
|
||||
return active
|
||||
? (hasChildren ? 'bg-primary/5 text-primary' : 'bg-primary/10 text-primary')
|
||||
: 'text-foreground hover:bg-primary-50 hover:text-primary';
|
||||
});
|
||||
|
||||
const getChildClass = (childRoute) => {
|
||||
return route().current(childRoute)
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground-1 hover:bg-primary-50 hover:text-primary';
|
||||
};
|
||||
|
||||
const onChildClick = () => {
|
||||
emit('child-click');
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="border-t border-layer-line px-3 py-4">
|
||||
<div class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-primary-50 transition-all duration-200 cursor-pointer group">
|
||||
<div class="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<DashboardIcon name="user" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{{ userName }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground-1">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="logout"
|
||||
class="p-1.5 rounded-lg hover:bg-rose-50 text-muted-foreground-1 hover:text-rose-600 transition-all duration-200 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
title="Выйти"
|
||||
aria-label="Выйти из системы"
|
||||
>
|
||||
<DashboardIcon name="arrow-right-on-rectangle" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePage, router } from '@inertiajs/vue3';
|
||||
import DashboardIcon from './DashboardIcon.vue';
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const userName = computed(() => page.props.auth?.user?.name || 'Пользователь');
|
||||
const userEmail = computed(() => page.props.auth?.user?.email || 'user@ntspi.ru');
|
||||
|
||||
const logout = () => {
|
||||
if (confirm('Вы уверены, что хотите выйти?')) {
|
||||
router.post(route('logout'), {}, {
|
||||
onSuccess: () => {
|
||||
// Redirect to login page after logout
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Sidebar menu configuration.
|
||||
* Each item defines a navigation section with optional collapsible children.
|
||||
*
|
||||
* @typedef {Object} MenuItem
|
||||
* @property {string} key - Unique identifier (used for expand state)
|
||||
* @property {string} label - Display name
|
||||
* @property {string} icon - Icon name (mapped in SidebarNavItem)
|
||||
* @property {string|null} route - Parent route name (null for simple links)
|
||||
* @property {Array<{label: string, route: string}>} [children] - Sub-items
|
||||
* @property {string[]} [activePrefixes] - Route prefixes to detect active state
|
||||
*/
|
||||
|
||||
/** @type {MenuItem[]} */
|
||||
export const menuItems = [
|
||||
{
|
||||
key: 'home',
|
||||
label: 'Главная',
|
||||
icon: 'home',
|
||||
route: 'dashboard.index',
|
||||
},
|
||||
{
|
||||
key: 'posts',
|
||||
label: 'Новости',
|
||||
icon: 'document',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.posts', 'dashboard.sliders'],
|
||||
children: [
|
||||
{ label: 'Все новости', route: 'dashboard.posts.index' },
|
||||
{ label: 'AI подготовка', route: 'dashboard.posts.ai-prepared' },
|
||||
{ label: 'Слайдеры', route: 'dashboard.sliders.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'additional-education',
|
||||
label: 'Дополнительное образование',
|
||||
icon: 'academic-cap',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.additional-educations'],
|
||||
children: [
|
||||
{ label: 'Все программы ДПО', route: 'dashboard.additional-educations.index' },
|
||||
{ label: 'Направления', route: 'dashboard.additional-educations.directions.index' },
|
||||
{ label: 'Категории', route: 'dashboard.additional-educations.categories.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admission-campaigns',
|
||||
label: 'Приемные кампании',
|
||||
icon: 'clipboard-document-check',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.admission-campaigns', 'dashboard.direction-studies', 'dashboard.educational-programs', 'dashboard.admission-plans'],
|
||||
children: [
|
||||
{ label: 'Все кампании', route: 'dashboard.admission-campaigns.index' },
|
||||
{ label: 'Направления подготовки', route: 'dashboard.direction-studies.index' },
|
||||
{ label: 'Образовательные программы', route: 'dashboard.educational-programs.index' },
|
||||
{ label: 'Планы приема', route: 'dashboard.admission-plans.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'schedules',
|
||||
label: 'Расписание',
|
||||
icon: 'calendar',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.schedules', 'dashboard.schedules.upload', 'dashboard.educational-groups'],
|
||||
children: [
|
||||
{ label: 'Все расписания', route: 'dashboard.schedules.index' },
|
||||
{ label: 'Загрузить файл', route: 'dashboard.schedules.upload.create' },
|
||||
{ label: 'Учебные группы', route: 'dashboard.educational-groups.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'institute-structure',
|
||||
label: 'Структура института',
|
||||
icon: 'building',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.faculties', 'dashboard.divisions', 'dashboard.departments'],
|
||||
children: [
|
||||
{ label: 'Факультеты', route: 'dashboard.faculties.index' },
|
||||
{ label: 'Кафедры', route: 'dashboard.departments.index' },
|
||||
{ label: 'Подразделения', route: 'dashboard.divisions.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'science',
|
||||
label: 'Научные журналы',
|
||||
icon: 'beaker',
|
||||
route: 'dashboard.academic-journals.index',
|
||||
activePrefixes: ['dashboard.academic-journals'],
|
||||
},
|
||||
{
|
||||
key: 'site-structure',
|
||||
label: 'Структура сайта',
|
||||
icon: 'folder',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.main-sections', 'dashboard.sub-sections', 'dashboard.pages'],
|
||||
children: [
|
||||
{ label: 'Главные разделы', route: 'dashboard.main-sections.index' },
|
||||
{ label: 'Подразделы', route: 'dashboard.sub-sections.index' },
|
||||
{ label: 'Страницы', route: 'dashboard.pages.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'widgets',
|
||||
label: 'Виджеты',
|
||||
icon: 'rectangle-stack',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.contact-widgets', 'dashboard.custom-forms', 'dashboard.page-reference-lists'],
|
||||
children: [
|
||||
{ label: 'Контактные виджеты', route: 'dashboard.contact-widgets.index' },
|
||||
{ label: 'Пользовательские формы', route: 'dashboard.custom-forms.index' },
|
||||
{ label: 'Списки ресурсов', route: 'dashboard.page-reference-lists.index' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: 'Пользователи',
|
||||
icon: 'user-circle',
|
||||
route: null,
|
||||
activePrefixes: ['dashboard.users'],
|
||||
children: [
|
||||
{ label: 'Все пользователи', route: 'dashboard.users.index' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Quick actions configuration.
|
||||
* These actions appear in the user profile area (below the user info).
|
||||
*
|
||||
* @typedef {Object} QuickAction
|
||||
* @property {string} label - Display name
|
||||
* @property {string} route - Route name for internal links
|
||||
* @property {string} icon - Icon name (mapped in SidebarUser)
|
||||
* @property {boolean} [external] - If true, uses href instead of route
|
||||
* @property {string} [href] - External URL (if external is true)
|
||||
*/
|
||||
|
||||
/** @type {QuickAction[]} */
|
||||
export const quickActions = [
|
||||
{
|
||||
label: 'Админ-панель',
|
||||
route: null,
|
||||
href: '/admin',
|
||||
icon: 'cog',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: 'Быстрая загрузка',
|
||||
route: 'dashboard.quick-upload.create',
|
||||
href: null,
|
||||
icon: 'upload',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div v-if="posts.length > 0" class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-5 border-b border-layer-line">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<DashboardIcon name="sparkles" size="4" class="text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">AI подготовленные публикации</h3>
|
||||
<p class="text-xs text-muted-foreground-1">Генерированы автоматически</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700">
|
||||
{{ posts.length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-layer-line">
|
||||
<li
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="p-4 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-foreground truncate mb-1">
|
||||
{{ post.title }}
|
||||
</h4>
|
||||
<p v-if="post.preview_text" class="text-xs text-muted-foreground-1 line-clamp-2">
|
||||
{{ post.preview_text }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium"
|
||||
:class="{
|
||||
'bg-amber-500/10 text-amber-700': post.status === 'verification',
|
||||
'bg-gray-500/10 text-gray-700': post.status === 'rejected',
|
||||
}"
|
||||
>
|
||||
{{ STATUS_LABEL(post.status) }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-3">•</span>
|
||||
<span class="text-xs text-muted-foreground-1">{{ FORMAT_DATE(post.created_at, 'short') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<Link
|
||||
:href="route('dashboard.posts.edit', post.id)"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-primary/20 text-xs font-medium rounded-md text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
Редактировать
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="p-4 border-t border-layer-line">
|
||||
<Link
|
||||
:href="route('dashboard.posts.ai-prepared')"
|
||||
class="text-sm text-primary hover:text-primary/80 transition-colors font-medium"
|
||||
>
|
||||
Посмотреть все →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'AiPreparedWidget',
|
||||
components: {
|
||||
Link,
|
||||
DashboardIcon,
|
||||
},
|
||||
props: {
|
||||
posts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<nav aria-label="Навигация" class="mb-4">
|
||||
<ol class="flex items-center gap-1.5 text-sm text-muted-foreground-1 flex-wrap">
|
||||
<li>
|
||||
<Link
|
||||
:href="route('dashboard.index')"
|
||||
class="inline-flex items-center gap-1 text-muted-foreground-1 hover:text-primary transition-colors"
|
||||
>
|
||||
<DashboardIcon name="home" size="4" />
|
||||
<span>Главная</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li v-for="(crumb, index) in crumbs" :key="index" class="flex items-center gap-1.5">
|
||||
<DashboardIcon name="chevron-right" size="3" class="text-muted-foreground-2" />
|
||||
<template v-if="crumb.href && index < crumbs.length - 1">
|
||||
<Link
|
||||
:href="crumb.href"
|
||||
class="text-muted-foreground-1 hover:text-primary transition-colors"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</Link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-foreground font-medium">{{ crumb.label }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'Breadcrumbs',
|
||||
components: {
|
||||
Link,
|
||||
DashboardIcon,
|
||||
},
|
||||
props: {
|
||||
crumbs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs mb-6">
|
||||
<div class="p-4 border-b border-line-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-muted-foreground-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<h2 class="text-sm font-medium text-foreground">{{ title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<slot></slot>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('reset')"
|
||||
class="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ resetText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DataFilters',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Фильтры'
|
||||
},
|
||||
resetText: {
|
||||
type: String,
|
||||
default: 'Сбросить фильтры'
|
||||
}
|
||||
},
|
||||
emits: ['reset']
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td :colspan="columns" class="px-6 py-16 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-surface border border-layer-line flex items-center justify-center">
|
||||
<DashboardIcon
|
||||
v-if="iconName"
|
||||
:name="iconName"
|
||||
size="8"
|
||||
class="text-muted-foreground-2"
|
||||
/>
|
||||
<svg v-else class="w-8 h-8 text-muted-foreground-2" fill="none" stroke="currentColor" :viewBox="iconViewBox">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPath" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-foreground font-medium">{{ title }}</p>
|
||||
<p class="text-sm text-muted-foreground-1 mt-1 mb-4">{{ description }}</p>
|
||||
<a
|
||||
v-if="actionUrl"
|
||||
:href="actionUrl"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" color="currentColor" />
|
||||
{{ actionText }}
|
||||
</a>
|
||||
<button
|
||||
v-else-if="onAction"
|
||||
@click="onAction"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" color="currentColor" />
|
||||
{{ actionText }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'EmptyState',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
},
|
||||
props: {
|
||||
columns: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Ничего не найдено'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Попробуйте изменить параметры поиска или создайте новую запись'
|
||||
},
|
||||
actionUrl: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: 'Создать'
|
||||
},
|
||||
onAction: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
iconPath: {
|
||||
type: String,
|
||||
default: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
|
||||
},
|
||||
iconViewBox: {
|
||||
type: String,
|
||||
default: '0 0 24 24'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Success Message -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="$page.props.flash?.success" class="mb-4 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.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>
|
||||
<span class="text-sm text-foreground font-medium">{{ $page.props.flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="$page.props.flash?.error" class="mb-4 p-4 bg-rose-500/10 border border-rose-500/20 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-rose-600 flex-shrink-0 mt-0.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 class="text-sm text-foreground font-medium">{{ $page.props.flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Local Success Message -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="localSuccess" class="mb-4 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.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>
|
||||
<span class="text-sm text-foreground font-medium">{{ localSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Local Error Message -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="localError" class="mb-4 p-4 bg-rose-500/10 border border-rose-500/20 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-rose-600 flex-shrink-0 mt-0.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 class="text-sm text-foreground font-medium">{{ localError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FlashMessages',
|
||||
props: {
|
||||
localSuccess: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
localError: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div v-if="data.last_page > 1" class="px-6 py-4 border-t border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground-1">
|
||||
Показано <span class="font-medium text-foreground">{{ data.from }}</span> – <span class="font-medium text-foreground">{{ data.to }}</span> из <span class="font-medium text-foreground">{{ data.total }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-for="(link, index) in data.links" :key="index">
|
||||
<a
|
||||
v-if="link.url"
|
||||
:href="link.url"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-sm font-medium rounded-lg border transition-all duration-200',
|
||||
link.active
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'bg-surface text-foreground border-layer-line hover:bg-muted-hover hover:border-primary/30'
|
||||
]"
|
||||
v-html="link.label"
|
||||
></a>
|
||||
<span
|
||||
v-else
|
||||
class="px-3 py-1.5 text-sm text-muted-foreground-2 bg-surface border border-layer-line rounded-lg"
|
||||
v-html="link.label"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Pagination',
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
|
||||
<h3 class="text-sm font-semibold text-foreground mb-4">Быстрые действия</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Link
|
||||
v-for="action in actions"
|
||||
:key="action.route"
|
||||
:href="route(action.route)"
|
||||
class="group flex items-center gap-3 p-3 rounded-lg border border-transparent transition-all duration-150"
|
||||
:class="action.hoverClass"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors" :class="action.bgClass">
|
||||
<DashboardIcon :name="action.icon" size="4" :class="action.iconClass" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground transition-colors" :class="action.textClass">
|
||||
{{ action.label }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground-1">{{ action.desc }}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'QuickActions',
|
||||
components: {
|
||||
Link,
|
||||
DashboardIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
route: 'dashboard.posts.create',
|
||||
label: 'Создать новость',
|
||||
desc: 'Новая публикация на сайт',
|
||||
icon: 'plus',
|
||||
bgClass: 'bg-blue-500/10 group-hover:bg-blue-500/20',
|
||||
iconClass: 'text-blue-600',
|
||||
textClass: 'group-hover:text-primary',
|
||||
hoverClass: 'hover:border-primary/20 hover:bg-primary/5',
|
||||
},
|
||||
{
|
||||
route: 'dashboard.schedules.upload.create',
|
||||
label: 'Загрузить расписание',
|
||||
desc: 'Массовая загрузка файлов',
|
||||
icon: 'arrow-up-tray',
|
||||
bgClass: 'bg-emerald-500/10 group-hover:bg-emerald-500/20',
|
||||
iconClass: 'text-emerald-600',
|
||||
textClass: 'group-hover:text-emerald-600',
|
||||
hoverClass: 'hover:border-emerald-500/20 hover:bg-emerald-500/5',
|
||||
},
|
||||
{
|
||||
route: 'dashboard.educational-groups.create',
|
||||
label: 'Добавить группу',
|
||||
desc: 'Учебная группа',
|
||||
icon: 'user-group',
|
||||
bgClass: 'bg-violet-500/10 group-hover:bg-violet-500/20',
|
||||
iconClass: 'text-violet-600',
|
||||
textClass: 'group-hover:text-violet-600',
|
||||
hoverClass: 'hover:border-violet-500/20 hover:bg-violet-500/5',
|
||||
},
|
||||
{
|
||||
route: 'dashboard.quick-upload.create',
|
||||
label: 'Быстрая загрузка',
|
||||
desc: 'Загрузка файлов',
|
||||
icon: 'paper-clip',
|
||||
bgClass: 'bg-amber-500/10 group-hover:bg-amber-500/20',
|
||||
iconClass: 'text-amber-600',
|
||||
textClass: 'group-hover:text-amber-600',
|
||||
hoverClass: 'hover:border-amber-500/20 hover:bg-amber-500/5',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<div class="p-5 border-b border-layer-line">
|
||||
<h3 class="text-sm font-semibold text-foreground">Последняя активность</h3>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-layer-line">
|
||||
<!-- Recent Posts -->
|
||||
<div v-if="recentActivity.recent_posts.length > 0" class="p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase tracking-wide">Публикации</h4>
|
||||
<Link :href="route('dashboard.posts.index')" class="text-xs text-primary hover:text-primary/80 transition-colors">
|
||||
Все новости →
|
||||
</Link>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="post in recentActivity.recent_posts" :key="post.id" class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-amber-500': post.status === 'verification',
|
||||
'bg-emerald-500': post.status === 'published',
|
||||
'bg-gray-400': post.status === 'rejected',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Link
|
||||
:href="route('dashboard.posts.edit', post.id)"
|
||||
class="text-sm font-medium text-foreground hover:text-primary transition-colors truncate block"
|
||||
>
|
||||
{{ post.title }}
|
||||
</Link>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-muted-foreground-1">{{ FORMAT_DATE(post.created_at, 'short') }}</span>
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium"
|
||||
:class="{
|
||||
'bg-amber-500/10 text-amber-700': post.status === 'verification',
|
||||
'bg-emerald-500/10 text-emerald-700': post.status === 'published',
|
||||
'bg-gray-500/10 text-gray-700': post.status === 'rejected',
|
||||
}"
|
||||
>
|
||||
{{ STATUS_LABEL(post.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Recent Schedules -->
|
||||
<div v-if="recentActivity.recent_schedules.length > 0" class="p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase tracking-wide">Расписания</h4>
|
||||
<Link :href="route('dashboard.schedules.index')" class="text-xs text-primary hover:text-primary/80 transition-colors">
|
||||
Все расписания →
|
||||
</Link>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="schedule in recentActivity.recent_schedules" :key="schedule.id" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<DashboardIcon name="calendar" size="4" class="text-emerald-600 flex-shrink-0" />
|
||||
<span class="text-sm text-foreground truncate">
|
||||
{{ schedule.educational_group?.title || 'Без группы' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground-1 flex-shrink-0">{{ FORMAT_DATE(schedule.created_at, 'short') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sliders -->
|
||||
<div v-if="recentActivity.recent_sliders.length > 0" class="p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase tracking-wide">Слайдеры</h4>
|
||||
<Link :href="route('dashboard.sliders.index')" class="text-xs text-primary hover:text-primary/80 transition-colors">
|
||||
Все слайдеры →
|
||||
</Link>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="slider in recentActivity.recent_sliders" :key="slider.id" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<DashboardIcon name="photo" size="4" class="text-rose-600 flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm text-foreground block truncate">{{ slider.title }}</span>
|
||||
<span class="text-xs text-muted-foreground-1">{{ slider.slides_count }} слайд(ов)</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground-1 flex-shrink-0">{{ FORMAT_DATE(slider.created_at, 'short') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="isEmpty" class="p-8 text-center">
|
||||
<DashboardIcon name="clock" size="8" class="text-muted-foreground-3 mx-auto mb-3" />
|
||||
<p class="text-sm text-muted-foreground-1">Пока нет активности</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'RecentActivity',
|
||||
components: {
|
||||
Link,
|
||||
DashboardIcon,
|
||||
},
|
||||
props: {
|
||||
recentActivity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
recent_posts: [],
|
||||
recent_schedules: [],
|
||||
recent_sliders: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isEmpty() {
|
||||
return (
|
||||
this.recentActivity.recent_posts.length === 0 &&
|
||||
this.recentActivity.recent_schedules.length === 0 &&
|
||||
this.recentActivity.recent_sliders.length === 0
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<label :for="id" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@keyup.enter="$emit('search')"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="w-full pl-9 pr-4 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 w-4 h-4 text-muted-foreground-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SearchInput',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Поиск'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Введите текст...'
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: 'search'
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'search']
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<label :for="id" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
|
||||
{{ label }}
|
||||
</label>
|
||||
<select
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value); $emit('change')"
|
||||
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
>
|
||||
<option value="">{{ placeholder }}</option>
|
||||
<slot></slot>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SelectFilter',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Фильтр'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Все'
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: 'filter'
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'change']
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="card in statCards"
|
||||
:key="card.label"
|
||||
class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="card.bgClass">
|
||||
<DashboardIcon :name="card.icon" size="5" :class="card.iconClass" />
|
||||
</div>
|
||||
<span
|
||||
v-if="card.weekCount > 0"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="card.badgeClass"
|
||||
>
|
||||
+{{ card.weekCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-2xl font-semibold text-foreground">{{ card.total }}</p>
|
||||
<p class="text-xs text-muted-foreground-1">{{ card.label }}</p>
|
||||
</div>
|
||||
<div v-if="card.link" class="mt-3 pt-3 border-t border-layer-line">
|
||||
<a
|
||||
:href="route(card.link.route)"
|
||||
class="text-xs font-medium transition-colors"
|
||||
:class="card.link.class"
|
||||
>
|
||||
{{ card.link.text }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'StatsOverview',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
},
|
||||
props: {
|
||||
stats: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
statCards() {
|
||||
return [
|
||||
{
|
||||
icon: 'document',
|
||||
bgClass: 'bg-blue-500/10',
|
||||
iconClass: 'text-blue-600',
|
||||
badgeClass: 'bg-blue-500/10 text-blue-700',
|
||||
total: this.stats.posts.total,
|
||||
weekCount: this.stats.posts.week,
|
||||
label: 'Всего публикаций',
|
||||
link: this.stats.posts.verification > 0
|
||||
? {
|
||||
route: 'dashboard.posts.index',
|
||||
text: `${this.stats.posts.verification} на модерации →`,
|
||||
class: 'text-amber-600 hover:text-amber-700',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
{
|
||||
icon: 'calendar',
|
||||
bgClass: 'bg-emerald-500/10',
|
||||
iconClass: 'text-emerald-600',
|
||||
badgeClass: 'bg-emerald-500/10 text-emerald-700',
|
||||
total: this.stats.schedules.total,
|
||||
weekCount: this.stats.schedules.week,
|
||||
label: 'Расписаний',
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
icon: 'academic-cap',
|
||||
bgClass: 'bg-violet-500/10',
|
||||
iconClass: 'text-violet-600',
|
||||
badgeClass: 'bg-violet-500/10 text-violet-700',
|
||||
total: this.stats.educational_groups.total,
|
||||
weekCount: this.stats.educational_groups.week,
|
||||
label: 'Учебных групп',
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
icon: 'photo',
|
||||
bgClass: 'bg-rose-500/10',
|
||||
iconClass: 'text-rose-600',
|
||||
badgeClass: 'bg-rose-500/10 text-rose-700',
|
||||
total: this.stats.sliders.total,
|
||||
weekCount: this.stats.sliders.week,
|
||||
label: 'Слайдеров',
|
||||
link: null,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.contact-widgets.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Создание контактного виджета</h1>
|
||||
<p class="text-xs text-muted-foreground-1">Заполните информацию о новом виджете</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-line-2">
|
||||
<nav class="flex -mb-px">
|
||||
<button
|
||||
@click="activeTab = 'main'"
|
||||
class="px-6 py-4 text-sm font-medium border-b-2 transition-all"
|
||||
:class="activeTab === 'main' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="information-circle" size="4" />
|
||||
Основная информация
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'content'"
|
||||
class="px-6 py-4 text-sm font-medium border-b-2 transition-all"
|
||||
:class="activeTab === 'content' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="4" />
|
||||
Содержание
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<!-- Tab: Main Info -->
|
||||
<div v-if="activeTab === 'main'" class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название ресурса <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@input="generateSlug"
|
||||
placeholder="Например: Контакты"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-адрес (Slug) <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
placeholder="Например: contacts"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground-1">
|
||||
Автоматически генерируется из названия. Можно изменить.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-layer-line text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активность ресурса
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Content -->
|
||||
<div v-if="activeTab === 'content'" class="space-y-6">
|
||||
<!-- Columns Repeater -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Столбцы с контактами <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addColumn"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить столбец
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="column in form.content"
|
||||
:key="column._uid"
|
||||
class="border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Column Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<input
|
||||
v-model="column.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Заголовок столбца (например: Контакты)"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeColumn(columnIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
:disabled="form.content.length <= 1"
|
||||
title="Удалить столбец"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Column Items -->
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Контактные блоки
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addItem(columnIndex)"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить контактный блок
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(item, itemIndex) in column.items"
|
||||
:key="item._uid"
|
||||
class="border border-layer-line rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<!-- Item Header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="item.header"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Заголовок контакта (например: Телефон)"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeItem(columnIndex, itemIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить блок"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Item Details -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Детали контакта
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addDetail(columnIndex, itemIndex)"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить деталь
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(detail, detailIndex) in item.details"
|
||||
:key="detail._uid"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="detail.content"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Значение (например: +7 (123) 456-78-90)"
|
||||
class="px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<input
|
||||
v-model="detail.url"
|
||||
type="text"
|
||||
placeholder="Ссылка (необязательно)"
|
||||
class="px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeDetail(columnIndex, itemIndex, detailIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить деталь"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.contact-widgets.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Создать виджет' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContactWidgetsCreate',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'main',
|
||||
form: {
|
||||
title: '',
|
||||
slug: '',
|
||||
is_active: true,
|
||||
content: []
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Создание контактного виджета');
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateSlug() {
|
||||
if (!this.form.slug) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
addColumn() {
|
||||
this.form.content.push({
|
||||
_uid: `col-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: '',
|
||||
items: []
|
||||
});
|
||||
},
|
||||
|
||||
removeColumn(index) {
|
||||
if (this.form.content.length > 1) {
|
||||
this.form.content.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
addItem(columnIndex) {
|
||||
this.form.content[columnIndex].items.push({
|
||||
_uid: `item-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
header: '',
|
||||
details: []
|
||||
});
|
||||
},
|
||||
|
||||
removeItem(columnIndex, itemIndex) {
|
||||
this.form.content[columnIndex].items.splice(itemIndex, 1);
|
||||
},
|
||||
|
||||
addDetail(columnIndex, itemIndex) {
|
||||
this.form.content[columnIndex].items[itemIndex].details.push({
|
||||
_uid: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
content: '',
|
||||
url: ''
|
||||
});
|
||||
},
|
||||
|
||||
removeDetail(columnIndex, itemIndex, detailIndex) {
|
||||
this.form.content[columnIndex].items[itemIndex].details.splice(detailIndex, 1);
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.post(route('dashboard.contact-widgets.store'), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background-2">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10 h-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div class="flex items-center h-full gap-3">
|
||||
<a
|
||||
:href="route('dashboard.contact-widgets.index')"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="5" />
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-medium text-foreground">Редактирование контактного виджета</h1>
|
||||
<p class="text-xs text-muted-foreground-1">{{ widget.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-line-2">
|
||||
<nav class="flex -mb-px">
|
||||
<button
|
||||
@click="activeTab = 'main'"
|
||||
class="px-6 py-4 text-sm font-medium border-b-2 transition-all"
|
||||
:class="activeTab === 'main' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="information-circle" size="4" />
|
||||
Основная информация
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'content'"
|
||||
class="px-6 py-4 text-sm font-medium border-b-2 transition-all"
|
||||
:class="activeTab === 'content' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DashboardIcon name="document-text" size="4" />
|
||||
Содержание
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="p-6">
|
||||
<!-- Tab: Main Info -->
|
||||
<div v-if="activeTab === 'main'" class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-2">
|
||||
Название ресурса <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
@input="generateSlug"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.title ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-500">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-foreground mb-2">
|
||||
URL-адрес (Slug) <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
:class="[
|
||||
'w-full px-4 py-2.5 bg-surface border rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all',
|
||||
errors.slug ? 'border-rose-500' : 'border-layer-line focus:border-primary'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-500">{{ errors.slug }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-layer-line text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-foreground">
|
||||
Активность ресурса
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Content -->
|
||||
<div v-if="activeTab === 'content'" class="space-y-6">
|
||||
<!-- Columns Repeater -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Столбцы с контактами <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addColumn"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить столбец
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="column in form.content"
|
||||
:key="column._uid"
|
||||
class="border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Column Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<input
|
||||
v-model="column.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Заголовок столбца (например: Контакты)"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeColumn(columnIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
:disabled="form.content.length <= 1"
|
||||
title="Удалить столбец"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Column Items -->
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Контактные блоки
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addItem(columnIndex)"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить контактный блок
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(item, itemIndex) in column.items"
|
||||
:key="item._uid"
|
||||
class="border border-layer-line rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<!-- Item Header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="item.header"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Заголовок контакта (например: Телефон)"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeItem(columnIndex, itemIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить блок"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Item Details -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Детали контакта
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addDetail(columnIndex, itemIndex)"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить деталь
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(detail, detailIndex) in item.details"
|
||||
:key="detail._uid"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="detail.content"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Значение (например: +7 (123) 456-78-90)"
|
||||
class="px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<input
|
||||
v-model="detail.url"
|
||||
type="text"
|
||||
placeholder="Ссылка (необязательно)"
|
||||
class="px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeDetail(columnIndex, itemIndex, detailIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить деталь"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-line-2">
|
||||
<a
|
||||
:href="route('dashboard.contact-widgets.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="processing" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<DashboardIcon v-else name="check" size="4" />
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContactWidgetsEdit',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FlashMessages
|
||||
},
|
||||
|
||||
props: {
|
||||
widget: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'main',
|
||||
form: {
|
||||
title: this.widget.title,
|
||||
slug: this.widget.slug,
|
||||
is_active: this.widget.is_active === 1 || this.widget.is_active === true || this.widget.is_active === '1',
|
||||
content: this.normalizeContent(this.widget.content || [])
|
||||
},
|
||||
errors: {},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE(`Редактирование: ${this.widget.title}`);
|
||||
},
|
||||
|
||||
methods: {
|
||||
normalizeContent(content) {
|
||||
// Добавляем _uid для существующих элементов, если их нет
|
||||
return content.map(column => ({
|
||||
...column,
|
||||
_uid: column._uid || `col-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
items: (column.items || []).map(item => ({
|
||||
...item,
|
||||
_uid: item._uid || `item-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
details: (item.details || []).map(detail => ({
|
||||
...detail,
|
||||
_uid: detail._uid || `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
},
|
||||
|
||||
generateSlug() {
|
||||
if (!this.form.slug || this.form.slug === this.widget.slug) {
|
||||
this.form.slug = this.GENERATE_SLUG(this.form.title);
|
||||
}
|
||||
},
|
||||
|
||||
addColumn() {
|
||||
this.form.content.push({
|
||||
_uid: `col-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: '',
|
||||
items: []
|
||||
});
|
||||
},
|
||||
|
||||
removeColumn(index) {
|
||||
if (this.form.content.length > 1) {
|
||||
this.form.content.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
addItem(columnIndex) {
|
||||
this.form.content[columnIndex].items.push({
|
||||
_uid: `item-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
header: '',
|
||||
details: []
|
||||
});
|
||||
},
|
||||
|
||||
removeItem(columnIndex, itemIndex) {
|
||||
this.form.content[columnIndex].items.splice(itemIndex, 1);
|
||||
},
|
||||
|
||||
addDetail(columnIndex, itemIndex) {
|
||||
this.form.content[columnIndex].items[itemIndex].details.push({
|
||||
_uid: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
content: '',
|
||||
url: ''
|
||||
});
|
||||
},
|
||||
|
||||
removeDetail(columnIndex, itemIndex, detailIndex) {
|
||||
this.form.content[columnIndex].items[itemIndex].details.splice(detailIndex, 1);
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.errors = {};
|
||||
|
||||
this.$inertia.put(route('dashboard.contact-widgets.update', this.widget.id), this.form, {
|
||||
onFinish: () => {
|
||||
this.processing = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
this.errors = errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="phone" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Контактные виджеты</template>
|
||||
<template #header-subtitle>Управление контактной информацией</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.contact-widgets.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Создать виджет
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Filters Card -->
|
||||
<DataFilters title="Фильтры" @reset="resetFilters">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
label="Поиск по названию"
|
||||
placeholder="Введите название виджета..."
|
||||
@search="search"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
v-model="activeQuery"
|
||||
label="Статус"
|
||||
placeholder="Все статусы"
|
||||
@change="filterByActive"
|
||||
>
|
||||
<option value="1">Активные</option>
|
||||
<option value="0">Неактивные</option>
|
||||
</SelectFilter>
|
||||
</DataFilters>
|
||||
|
||||
<!-- Table Card -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Table Header Stats -->
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ widgets.total }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
{{ widgets.data.length }} на странице
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="refreshPage"
|
||||
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
|
||||
title="Обновить"
|
||||
>
|
||||
<DashboardIcon name="arrow-path" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Slug
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Дата создания
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="widget in widgets.data"
|
||||
:key="widget.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-muted-foreground-1">{{ widget.id }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ widget.title }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-xs bg-muted px-2 py-1 rounded text-foreground">
|
||||
{{ widget.slug }}
|
||||
</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="STATUS_BADGE_CLASS(widget.is_active)">
|
||||
{{ widget.is_active ? 'Активен' : 'Неактивен' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-foreground">{{ FORMAT_DATE(widget.created_at, 'full') }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.contact-widgets.edit', widget.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="confirmDeleteWidget(widget)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-if="widgets.data.length === 0"
|
||||
:columns="6"
|
||||
title="Контактные виджеты не найдены"
|
||||
description="Создайте первый виджет или измените параметры поиска"
|
||||
:action-url="route('dashboard.contact-widgets.create')"
|
||||
action-text="Создать виджет"
|
||||
icon-path="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :data="widgets" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import DataFilters from '../Components/shared/DataFilters.vue';
|
||||
import SearchInput from '../Components/shared/SearchInput.vue';
|
||||
import SelectFilter from '../Components/shared/SelectFilter.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
import Pagination from '../Components/shared/Pagination.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContactWidgetsIndex',
|
||||
components: {
|
||||
DashboardLayout,
|
||||
DashboardIcon,
|
||||
FlashMessages,
|
||||
DataFilters,
|
||||
SearchInput,
|
||||
SelectFilter,
|
||||
EmptyState,
|
||||
Pagination
|
||||
},
|
||||
|
||||
props: {
|
||||
widgets: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
search: '',
|
||||
is_active: ''
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: this.filters?.search || '',
|
||||
activeQuery: this.filters?.is_active || ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.SET_DOCUMENT_TITLE('Контактные виджеты');
|
||||
},
|
||||
|
||||
methods: {
|
||||
confirmDeleteWidget(widget) {
|
||||
this.CONFIRM_AND_DELETE(widget, 'dashboard.contact-widgets.destroy', {
|
||||
message: `Удалить контактный виджет "${widget.title}"?`
|
||||
});
|
||||
},
|
||||
|
||||
search() {
|
||||
this.INERTIA_FILTER('dashboard.contact-widgets.index', {
|
||||
search: this.searchQuery,
|
||||
is_active: this.activeQuery
|
||||
});
|
||||
},
|
||||
|
||||
filterByActive() {
|
||||
this.search();
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.RESET_FILTERS(
|
||||
['searchQuery', 'activeQuery'],
|
||||
'dashboard.contact-widgets.index'
|
||||
);
|
||||
},
|
||||
|
||||
refreshPage() {
|
||||
this.INERTIA_FILTER('dashboard.contact-widgets.index', {
|
||||
search: this.searchQuery,
|
||||
is_active: this.activeQuery
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user