diff --git a/ROADMAP.md b/ROADMAP.md index adc7322..5316ab4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,6 +36,15 @@ --- +**Доработки таблицы пользователей (добавить в рамках Этапа 9):** +- [ ] Фильтры: по роли (Ученик / Куратор / Администратор), по статусу email (подтверждён/нет) +- [ ] Поиск по имени / email +- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100) +- [ ] Ховер-попап на строке: список курсов пользователя со сроками доступа + email + телефон +- [ ] Быстрые действия в строке: кнопка «Выдать доступ» без перехода на страницу пользователя + +--- + ## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026) - [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным @@ -80,6 +89,13 @@ - [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает) - [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс) +**Доработки (добавить в рамках Этапа 9):** +- [ ] Фильтры в списке ДЗ: по имени/email ученика, по уроку, по курсу, по статусу +- [ ] Поиск по имени/email ученика +- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100) +- [ ] Расширенные статусы: «Новое» / «Просмотрено» / «Прокомментировано» +- [ ] Шаблоны ответов куратора — готовые тексты фидбека, выбираемые из списка + --- ## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026) @@ -90,6 +106,13 @@ - [x] Модерация: автор, куратор или admin может удалить комментарий - [x] Счётчик активных комментариев в заголовке секции +**Не реализовано (добавить в Этап 9 или отдельно):** +- [ ] `/admin/comments` — сводная таблица всех комментариев по всем урокам + - Колонки: №, Имя ученика, Урок (ссылка), Время, кол-во ответов, превью текста + - Удалить комментарий прямо из списка + - Пагинация + - Ссылка в сайдбаре AdminNav + --- ## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026) @@ -114,31 +137,128 @@ --- -## Этап 9 — Миграция с emdesell ← СЛЕДУЮЩИЙ -**Цель:** все пользователи и контент перенесены в новую LMS. +## Этап 9 — Настройки платформы (Admin Settings) ✅ ЗАВЕРШЁН (08.04.2026) +**Цель:** администратор управляет ключевыми параметрами платформы без правки кода. -- [ ] Скрипт импорта пользователей из CSV-экспорта emdesell (email, имя, курсы) -- [ ] Создание пользователей без пароля + письмо «установите пароль» -- [ ] Назначение доступов к курсам по данным из CSV +### Основное +- [ ] Название школы (используется в заголовке сайта, подписи писем) +- [ ] Описание школы (мета-тег description) +- [ ] Ключевые слова (мета-тег keywords) +- [ ] Режим тех. работ: вкл/выкл (показывает заглушку всем кроме admin) +- [ ] Регистрация учеников: вкл/выкл + +### Оформление +- [ ] Логотип школы (загрузка → Object Storage, отображается в шапке) +- [ ] Фавикон (загрузка → Object Storage) +- [ ] Показывать логотип: да/нет + +### Уведомления +- [ ] Email(ы) для системных уведомлений (кому слать письма о ДЗ, вопросах, регистрациях) +- [ ] Уведомление куратору/админу о новом ДЗ: вкл/выкл +- [ ] Уведомление куратору/админу о новом вопросе ученика: вкл/выкл +- [ ] Уведомление админу о новой регистрации: вкл/выкл +- [ ] Уведомление ученику при ответе на ДЗ/вопрос: вкл/выкл + +### Данные ученика +- [ ] Требовать подтверждение email перед доступом к курсам: да/нет +- [ ] Фамилия при регистрации: обязательная / необязательная / выключена +- [ ] Телефон при регистрации: обязательный / необязательный / выключен + +### Защита +- [ ] Одна активная сессия на аккаунт: вкл/выкл +- [ ] CAPTCHA на форме регистрации: вкл/выкл (reCAPTCHA v3) + +### Права куратора +- [ ] Куратор видит ДЗ: по всем курсам / только по назначенным курсам +- [ ] Куратор может отвечать на вопросы учеников: да/нет +- [ ] Куратор видит список всех студентов: да/нет + +### Вставка кода +- [ ] Произвольный код в `` (Yandex.Metrika, Google Analytics, пиксели) +- [ ] Произвольный код в `` (виджеты, чаты поддержки) + +### Юридические документы +- [ ] URL Политики конфиденциальности (ссылка на внешний документ) +- [ ] URL Согласия на обработку персональных данных +- [ ] URL Договора-оферты +- [ ] Показывать чекбокс «Я принимаю условия» при регистрации: да/нет +- [ ] Реквизиты организации (текстовое поле, отображается в подвале) + +### Соц. сети +- [ ] YouTube: одна ссылка +- [ ] VK: несколько ссылок (название + URL), например «Основная группа» и «Канал» +- [ ] Telegram: несколько ссылок (название + URL), например «Основной канал» и «Канал курса» +(отображаются в подвале личного кабинета ученика; хранятся как JSON-массив в Settings) + +### Вопросы учеников +- [ ] Система вопросов глобально: вкл/выкл +- [ ] Куратор/админ может написать ученику первым: да/нет +- [ ] Вопросы только по курсам ученика: да/нет +- [ ] Включать вопросы для новых курсов автоматически: да/нет + +**Хранение:** таблица `Settings` (key-value), доступна через `getSettings()` в server components. +**Критерий готовности:** меняю название школы → оно появляется в заголовке. Включаю тех. работы → ученики видят заглушку. Куратор привязан к курсу — видит только его ДЗ. + +--- + +## Этап 11 — Импорт/Экспорт учеников и миграция с emdesell +**Цель:** все пользователи и контент перенесены в новую LMS. Раздел `/admin/import-export`. + +### Импорт учеников (CSV) +- [ ] Скачать файл-шаблон CSV (Email, Имя, Фамилия, Телефон) +- [ ] Загрузка CSV, поддержка кодировок Windows-1251 и UTF-8 +- [ ] Опция: подтверждать email автоматически (да/нет) +- [ ] Опция: обновлять уже существующие аккаунты (да/нет) +- [ ] Присвоение доступов к курсам при импорте (выбор курса + срок в днях, 0 = бессрочно) +- [ ] Опция: отправить письмо-уведомление ученику (со ссылкой для установки пароля) +- [ ] Предпросмотр перед применением (таблица: кто создаётся, кто обновляется, кому даётся доступ) +- [ ] Применить импорт — создать пользователей, выдать доступы, отправить письма + +### Экспорт учеников (CSV) +- [ ] Все ученики или фильтр по конкретному курсу/доступу +- [ ] Фильтр по просмотрам уроков (экспортировать только тех кто смотрел) +- [ ] Выбор кодировки: Windows-1251 (для Excel) / UTF-8 +- [ ] Поля: Email, Имя, Фамилия, Телефон, Дата регистрации, Курсы, Прогресс + +### Миграция контента - [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов) - [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам - [ ] QA: проверить 10 случайных аккаунтов после импорта -**Критерий готовности:** все ученики из emdesell могут войти в новую LMS и продолжить обучение. +**Критерий готовности:** загружаю CSV из emdesell → предпросмотр показывает корректные данные → применяю → ученики получают письма → могут войти и продолжить обучение. --- -## Этап 10 — Telegram-бот и аналитика -**Цель:** получаю уведомления в Telegram, вижу базовую аналитику. +## Этап 12 — Telegram-бот и аналитика +**Цель:** уведомления в Telegram для всех участников, базовая аналитика. -- [ ] Telegram-бот: уведомление куратору/админу о новом ДЗ -- [ ] Telegram-бот: уведомление об ошибках (500-е, failed email и т.д.) +**Настройки (в разделе Настройки → Telegram):** +- Токен бота (вводится в админке, хранится в Settings) +- Интеграция вкл/выкл глобально +- Показывать кнопку «Подключить Telegram» в кабинете ученика: да/нет + +**Уведомления куратору/админу:** +- [ ] Новое ДЗ на проверку +- [ ] Новый вопрос от ученика +- [ ] Новая регистрация студента +- [ ] Ошибки платформы (500-е, failed email и т.д.) + +**Уведомления ученику:** +- [ ] Получен фидбек по ДЗ +- [ ] Ответ куратора на вопрос +- [ ] Открыт доступ к новому курсу + +**Реализация:** +- [ ] Ученик привязывает Telegram через `/start` в боте (сохраняется `telegramChatId` в User) +- [ ] Кнопка «Подключить Telegram» в личном кабинете ученика +- [ ] Админ/куратор вводит свой `chatId` в профиле или через `/start` +- [ ] Настройки бота в разделе Настройки → Telegram - [ ] Yandex.Metrika: базовое подключение (pageviews) - [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам) --- -## Этап 11 — Тесты и квизы +## Этап 13 — Тесты и квизы **Цель:** можно добавить тест к уроку, ученик проходит и получает результат. - [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть) @@ -164,3 +284,35 @@ - Kinescope DRM (signed URLs) — при переходе на платный план - Водяные знаки на PDF и картинках - Мобильное приложение +- **Вопросы учеников** — система тикетов `/admin/questions` и `/questions` для ученика: + - Таблица в админке: №, Имя, Курс, Тема, Статус (Ожидает / Отвечено), Дата + - Статусы отсортированы: сначала «Ожидает ответа» + - Куратор/Admin может создать обращение первым (написать ученику) + - Внутри тикета: история переписки, смена статуса + - **База знаний** — FAQ, который ученик видит до отправки вопроса + - **Шаблоны ответов** — куратор выбирает готовый ответ из списка + - Email + Telegram уведомления обеим сторонам + +- **Главная страница ученика** — кастомизируемый экран после входа: + - Приветственный баннер с описанием школы (редактируется в настройках) + - Список курсов ученика с прогрессом + - Блок бесплатных/открытых материалов (статьи, PDF, видео) + - Анонсы ближайших событий и новых курсов + +- **Медиатека (Файлы)** — централизованное файловое хранилище `/admin/files`: + - Prisma: `MediaFolder` (id, name, courseId?, createdAt) + `MediaFile` (id, folderId?, name, url, size, mimeType, uploadedById, createdAt) + - Папки автоматически создаются по курсам + «Common» для общих файлов + - Вид: грид (карточки с иконкой типа) или список — переключатель + - Breadcrumb-навигация: Все файлы / Название папки + - Загрузка файлов (PDF, изображения, любые) → Object Storage + - Создание папки вручную + - Клик на файл → диалог: имя (редактируемое), дата загрузки, размер, автор + - Действия в диалоге: скопировать ссылку, скачать, удалить + - Вставка файлов из медиатеки в урок (вместо повторной загрузки) + +- **Цифровой сад** — публичный раздел платформы для сообщества: + - Методические материалы и статьи (PKM, Obsidian, Second Brain) + - Рекомендованная литература с аннотациями + - Записи открытых встреч и вебинаров + - Календарь: предстоящие открытые уроки, запуски курсов, события + - Возможно: публичный Obsidian-like граф знаний diff --git a/prisma/migrations/20260408100000_add_settings/migration.sql b/prisma/migrations/20260408100000_add_settings/migration.sql new file mode 100644 index 0000000..64ffdeb --- /dev/null +++ b/prisma/migrations/20260408100000_add_settings/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "Settings" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Settings_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39ec51b..462d932 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -295,3 +295,13 @@ model LessonComment { lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) } + +// ───────────────────────────────────────────── +// Platform Settings (key-value store) +// ───────────────────────────────────────────── + +model Settings { + key String @id + value String @db.Text + updatedAt DateTime @updatedAt +} diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx index 04b7953..058a13e 100644 --- a/src/app/(student)/layout.tsx +++ b/src/app/(student)/layout.tsx @@ -3,11 +3,18 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import Link from "next/link"; import { LogoutButton } from "@/components/layout/logout-button"; +import { getSetting } from "@/lib/settings"; export default async function StudentLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/login"); + // Maintenance mode: non-admin users see the maintenance page + if (session.user.role !== "admin") { + const maintenance = await getSetting("maintenanceMode"); + if (maintenance === "true") redirect("/maintenance"); + } + return (
) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Нет доступа"); + + const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[]; + const ops = validKeys + .filter((key) => key in data) + .map((key) => + prisma.settings.upsert({ + where: { key }, + update: { value: data[key] }, + create: { key, value: data[key] }, + }) + ); + + await Promise.all(ops); + revalidatePath("/admin/settings"); + revalidatePath("/", "layout"); +} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..d5f7132 --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,22 @@ +import { getSettings } from "@/lib/settings"; +import { SettingsForm } from "@/components/admin/settings-form"; + +export const metadata = { title: "Настройки платформы" }; + +export default async function SettingsPage() { + const settings = await getSettings(); + + return ( +
+
+

+ Настройки платформы +

+
+ +
+ ); +} diff --git a/src/app/curator/layout.tsx b/src/app/curator/layout.tsx index 239278a..894648c 100644 --- a/src/app/curator/layout.tsx +++ b/src/app/curator/layout.tsx @@ -4,12 +4,19 @@ import { redirect } from "next/navigation"; import Link from "next/link"; import { LogoutButton } from "@/components/layout/logout-button"; import { AdminShell } from "@/components/admin/admin-shell"; +import { getSetting } from "@/lib/settings"; export default async function CuratorLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/login"); if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard"); + // Maintenance mode: curators (non-admin) see the maintenance page + if (session.user.role === "curator") { + const maintenance = await getSetting("maintenanceMode"); + if (maintenance === "true") redirect("/maintenance"); + } + // Admin uses the admin shell with sidebar if (session.user.role === "admin") { return {children}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 37b5e7c..24a9994 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Fira_Mono } from "next/font/google"; import "./globals.css"; +import { getSettings } from "@/lib/settings"; const firaMono = Fira_Mono({ weight: ["400", "500", "700"], @@ -9,19 +10,33 @@ const firaMono = Fira_Mono({ display: "swap", }); -export const metadata: Metadata = { - title: "Second Brain — Обучение", - description: "Образовательная платформа Second Brain", -}; +export async function generateMetadata(): Promise { + const settings = await getSettings(); + return { + title: `${settings.schoolName} — Обучение`, + description: settings.schoolDescription || "Образовательная платформа", + keywords: settings.schoolKeywords || undefined, + }; +} -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const settings = await getSettings(); + return ( - {children} + {settings.headCode ? ( + + ) : null} + + {children} + {settings.bodyCode ? ( +
+ ) : null} + ); } diff --git a/src/app/maintenance/page.tsx b/src/app/maintenance/page.tsx new file mode 100644 index 0000000..bf9fbac --- /dev/null +++ b/src/app/maintenance/page.tsx @@ -0,0 +1,23 @@ +export default function MaintenancePage() { + return ( +
+
+

+ Технические работы +

+

+ Скоро вернёмся +

+

+ Платформа временно недоступна. Мы проводим обновление — пожалуйста, зайдите позже. +

+
+
+ ); +} diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index 0c58791..256b864 100644 --- a/src/components/admin/admin-nav.tsx +++ b/src/components/admin/admin-nav.tsx @@ -9,6 +9,7 @@ const links = [ { href: "/admin/categories", label: "Категории" }, { href: "/admin/users", label: "Пользователи" }, { href: "/curator/homework", label: "ДЗ на проверку" }, + { href: "/admin/settings", label: "Настройки" }, ]; export function AdminNav() { diff --git a/src/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx new file mode 100644 index 0000000..7eb115e --- /dev/null +++ b/src/components/admin/settings-form.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Save } from "lucide-react"; +import { saveSettings } from "@/app/admin/settings/actions"; +import type { Settings } from "@/lib/settings"; + +// ── Small primitives ────────────────────────────────────────────────────────── + +const inputStyle = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + width: "100%", + padding: "0.5rem 0.75rem", + fontSize: "0.875rem", + fontFamily: "inherit", +} as React.CSSProperties; + +const focusHandlers = { + onFocus: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--foreground)"), + onBlur: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--border)"), +}; + +function Section({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {hint && ( +

+ {hint} +

+ )} +
+ {children} +
+ ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {hint && ( +

+ {hint} +

+ )} + {children} +
+ ); +} + +function Toggle({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint?: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+ +
+

+ {label} +

+ {hint && ( +

+ {hint} +

+ )} +
+
+ ); +} + +function SelectField({ + label, + hint, + value, + onChange, + options, +}: { + label: string; + hint?: string; + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) { + return ( + + + + ); +} + +// ── Main form ───────────────────────────────────────────────────────────────── + +export function SettingsForm({ initial }: { initial: Settings }) { + const [s, setS] = useState(initial); + const [saved, setSaved] = useState(false); + const [pending, startTransition] = useTransition(); + + function set(key: keyof Settings, value: string) { + setS((prev) => ({ ...prev, [key]: value })); + } + + function bool(key: keyof Settings) { + return s[key] === "true"; + } + + function handleSave() { + startTransition(async () => { + await saveSettings(s); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }); + } + + return ( +
+ {/* Save button */} +
+ +
+ + {/* ── 1. Основное ── */} +
+ + set("schoolName", e.target.value)} + style={inputStyle} + {...focusHandlers} + /> + + +