Add balance transactions to user admin panel
Introduces BalanceTransaction model to track per-user balance history (prepayments, refunds, partner credits). Admin can add/delete transactions; current balance is computed as the running sum. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,3 +45,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# Claude Code local plugins (external git repos, не коммитим)
|
||||||
|
.claude/plugins/
|
||||||
|
|||||||
@@ -1,5 +1,308 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
# AGENTS.md — LMS Second Brain
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
Собственная LMS-платформа для образовательных курсов по PKM и Obsidian.
|
||||||
<!-- END:nextjs-agent-rules -->
|
Заменяет emdesell.ru. Масштаб: ~1000 аккаунтов, ~200 активных, до 10 курсов.
|
||||||
|
Production: **https://school.second-brain.ru**
|
||||||
|
|
||||||
|
> Подробная техническая документация — в `TECHNICAL.md`.
|
||||||
|
> Роадмап и текущий статус — в `ROADMAP.md`.
|
||||||
|
> Полные правила для Claude Code — в `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Слой | Технология | Версия |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| Фреймворк | Next.js (App Router) | **16.2.2** |
|
||||||
|
| Язык | TypeScript (strict) | 5.x |
|
||||||
|
| UI | React | 19 |
|
||||||
|
| Стили | Tailwind CSS (CSS-based, **без** tailwind.config.ts) | 4.x |
|
||||||
|
| Компоненты | shadcn/ui (Base UI, **не Radix**) | v4 |
|
||||||
|
| ORM | Prisma | 7.x |
|
||||||
|
| Auth | Better Auth (**не NextAuth**) | 1.6.0 |
|
||||||
|
| Редактор | TipTap WYSIWYG | 2.x |
|
||||||
|
| Drag-and-drop | @dnd-kit | latest |
|
||||||
|
| БД | PostgreSQL | 16 |
|
||||||
|
| Email | Resend | latest |
|
||||||
|
| Хранилище | Hetzner Object Storage (S3-совместимый) | — |
|
||||||
|
| Видео | Kinescope (iframe embed) | — |
|
||||||
|
| Валидация | Zod | 3.x |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критические отличия от стандартных версий
|
||||||
|
|
||||||
|
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
|
||||||
|
|
||||||
|
### Next.js 16.2.2
|
||||||
|
- Используется `proxy.ts` вместо `middleware.ts`
|
||||||
|
- Экспортируемая функция называется `proxy`, не `middleware`
|
||||||
|
- Перед написанием кода смотри `node_modules/next/dist/docs/`
|
||||||
|
|
||||||
|
### Tailwind CSS v4
|
||||||
|
- **Нет файла `tailwind.config.ts`** — вся кастомизация через CSS
|
||||||
|
- Конфиг: `@import "tailwindcss"` и `@theme` в `globals.css`
|
||||||
|
|
||||||
|
### shadcn/ui v4
|
||||||
|
- Базируется на `@base-ui/react`, **не Radix**
|
||||||
|
- Нет пропа `asChild` — триггеры обычные элементы
|
||||||
|
- Установка: `npx shadcn@latest add <component>`
|
||||||
|
|
||||||
|
### Prisma 7.x
|
||||||
|
- Импорт: `from "@/generated/prisma/client"` (не `from "@/generated/prisma"`)
|
||||||
|
- Требует адаптер: `new PrismaPg({ connectionString })`
|
||||||
|
- Не генерирует `index.ts`
|
||||||
|
|
||||||
|
### Better Auth 1.6.0
|
||||||
|
- **Не путать с NextAuth** — другая библиотека, другое API
|
||||||
|
- В этом проекте используется **bcrypt** (не scrypt по умолчанию)
|
||||||
|
- Настройки `password.hash` / `password.verify` в `src/lib/auth.ts`
|
||||||
|
- `auth-client.ts` не использует `baseURL` — берёт `window.location.origin`
|
||||||
|
- Seed-пользователи вставлены через SQL с `emailVerified = true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Разработка
|
||||||
|
npm run dev # localhost:3000
|
||||||
|
docker compose up -d # Поднять PostgreSQL локально
|
||||||
|
|
||||||
|
# Проверка качества
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run type-check # tsc --noEmit
|
||||||
|
|
||||||
|
# Сборка
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
|
||||||
|
# База данных
|
||||||
|
npx prisma migrate dev --name <snake_case_name> # Новая миграция
|
||||||
|
npx prisma migrate deploy # Применить в production
|
||||||
|
npx prisma generate # Пересоздать клиент
|
||||||
|
npx prisma db seed # Заполнить тестовыми данными
|
||||||
|
npx prisma studio # GUI для БД
|
||||||
|
|
||||||
|
# Production-деплой (на сервере в /root/digital-household/lms-sb/)
|
||||||
|
git pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
При старте production-контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
lms-system/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/ # Next.js App Router
|
||||||
|
│ │ ├── (auth)/ # login, register, verify-email
|
||||||
|
│ │ ├── (student)/ # dashboard, courses/[slug], lessons/[lessonId]
|
||||||
|
│ │ ├── curator/ # homework review, dashboard
|
||||||
|
│ │ ├── admin/ # courses, users, settings, categories
|
||||||
|
│ │ └── api/ # REST endpoints + Better Auth handler
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # shadcn/ui (автогенерация, не трогать)
|
||||||
|
│ │ ├── editor/ # TipTap WYSIWYG
|
||||||
|
│ │ ├── player/ # Kinescope Player wrapper
|
||||||
|
│ │ ├── course/ # Компоненты курса
|
||||||
|
│ │ └── layout/ # Header, Sidebar, Footer
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── auth.ts # Better Auth config (сервер)
|
||||||
|
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
||||||
|
│ │ ├── prisma.ts # Prisma singleton
|
||||||
|
│ │ ├── s3.ts # Hetzner S3 клиент
|
||||||
|
│ │ ├── email.ts # Resend email helpers
|
||||||
|
│ │ └── utils.ts # cn() и утилиты
|
||||||
|
│ ├── types/ # TypeScript-типы
|
||||||
|
│ ├── proxy.ts # Auth middleware (защита маршрутов)
|
||||||
|
│ └── middleware.ts # Обёртка над proxy
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # Схема БД (~314 строк)
|
||||||
|
│ ├── seed.ts # Тестовые данные
|
||||||
|
│ └── migrations/ # НЕ РЕДАКТИРОВАТЬ ВРУЧНУЮ
|
||||||
|
├── docker-compose.yml # Локальная разработка
|
||||||
|
├── docker-compose.prod.yml # Production
|
||||||
|
├── Dockerfile # Multi-stage build
|
||||||
|
├── .env.example # Шаблон переменных (без секретов)
|
||||||
|
└── .env.local # Локальные секреты (в .gitignore)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роли и маршруты
|
||||||
|
|
||||||
|
| Роль | Маршруты | Описание |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `admin` | `/admin/*`, `/curator/*`, всё | Полный доступ |
|
||||||
|
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
||||||
|
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
||||||
|
|
||||||
|
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Модель данных (ключевые сущности)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → Session, Account, Verification # Better Auth
|
||||||
|
Category → Course → Module → Lesson # Структура контента
|
||||||
|
Lesson → LessonFile # Файлы к уроку
|
||||||
|
CourseEnrollment (userId + courseId) # Доступ с expiresAt
|
||||||
|
AccessLog # Аудит доступов
|
||||||
|
LessonProgress (userId + lessonId) # Прогресс ученика
|
||||||
|
Lesson → Homework → HomeworkSubmission → HomeworkFeedback # ДЗ
|
||||||
|
Lesson → LessonComment # Обсуждения (soft-delete)
|
||||||
|
Lesson → Quiz → QuizQuestion → QuizOption # Тесты
|
||||||
|
Quiz → QuizAttempt # Результаты тестов
|
||||||
|
Settings (key-value) # Настройки платформы
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дизайн-система «Second Brain Aubade»
|
||||||
|
|
||||||
|
Типографский, монохромный, газетный стиль.
|
||||||
|
|
||||||
|
| Токен | Значение |
|
||||||
|
|-------|---------|
|
||||||
|
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) |
|
||||||
|
| Фон | `#F5F5F0` (тёплый off-white) |
|
||||||
|
| Текст | `#323232` |
|
||||||
|
| Поверхность | `#E8E8E0` |
|
||||||
|
| Акцент | `#E8F0D8` (зелёный) |
|
||||||
|
| Border | `#AAAAAA` |
|
||||||
|
| Сайдбар | `#2A2A28` (тёмный) |
|
||||||
|
|
||||||
|
**Aubade-эффект** (карточки и кнопки):
|
||||||
|
- Border: `2px solid #AAAAAA`
|
||||||
|
- Shadow: `4px 4px 0 0 #AAAAAA`
|
||||||
|
- Hover: `translate(-2px, -2px)` + shadow `6px 6px`
|
||||||
|
- Active: `translate(2px, 2px)`, shadow убирается
|
||||||
|
- CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инфраструктура
|
||||||
|
|
||||||
|
| Компонент | Значение |
|
||||||
|
|-----------|---------|
|
||||||
|
| Сервер | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
|
||||||
|
| Reverse proxy | Caddy (auto HTTPS, Let's Encrypt) |
|
||||||
|
| Порт | 3010 (внутри контейнера 3000) |
|
||||||
|
| БД | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
|
||||||
|
| Object Storage | Hetzner S3, endpoint `nbg1.your-objectstorage.com`, бакет `second-brain-lms` |
|
||||||
|
| Git | Gitea — `https://git.second-brain.ru/admins/lms-sb` |
|
||||||
|
| Email | Resend, домен `mailsend.second-brain.ru` |
|
||||||
|
| Бэкапы | PostgreSQL → Backblaze B2 (ежедневно, 03:00, ротация 7 дней) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
|
||||||
|
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
RESEND_API_KEY=""
|
||||||
|
EMAIL_FROM="noreply@mailsend.second-brain.ru"
|
||||||
|
S3_ENDPOINT="https://nbg1.your-objectstorage.com"
|
||||||
|
S3_BUCKET="second-brain-lms"
|
||||||
|
S3_ACCESS_KEY=""
|
||||||
|
S3_SECRET_KEY=""
|
||||||
|
S3_REGION="eu-central"
|
||||||
|
```
|
||||||
|
|
||||||
|
Секреты — **только в `.env.local`**. При добавлении новых переменных обновлять `.env.example`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила написания кода
|
||||||
|
|
||||||
|
### Языки
|
||||||
|
- **UI-строки** (заголовки, кнопки, сообщения): русский
|
||||||
|
- **Переменные, функции, файлы, комментарии**: английский
|
||||||
|
- **Коммиты**: английский, imperative mood (`Add lesson progress`, `Fix auth redirect`)
|
||||||
|
|
||||||
|
### Стиль
|
||||||
|
- Server Actions для форм и мутаций
|
||||||
|
- Не добавлять абстракции «на будущее» — только текущий этап
|
||||||
|
- Нет `console.log` в production (только `console.error` для реальных ошибок)
|
||||||
|
- Нет захардкоженных секретов, URL, ID
|
||||||
|
|
||||||
|
### Миграции БД
|
||||||
|
- **Никогда** не редактировать `prisma/migrations/` вручную
|
||||||
|
- **Всегда** спрашивать перед миграцией, которая меняет или удаляет существующие поля
|
||||||
|
- Имена миграций: английский, snake_case (`add_lesson_progress`)
|
||||||
|
- Перед `prisma migrate deploy` на production — бэкап БД
|
||||||
|
|
||||||
|
### Файлы и загрузки
|
||||||
|
- Все файлы (ДЗ, PDF, изображения) — через Hetzner Object Storage, **не на диск VPS**
|
||||||
|
- Обложки курсов: 16:9, max 5 MB, JPG/PNG/WebP
|
||||||
|
- Изображения в уроках: max 10 MB
|
||||||
|
- Файлы к уроку: max 100 MB, PDF/ZIP/DOCX
|
||||||
|
|
||||||
|
### Коммиты
|
||||||
|
- Один коммит = одна логически завершённая единица
|
||||||
|
- Перед коммитом: `npm run lint && npm run type-check`
|
||||||
|
- После завершения каждого этапа ROADMAP — `git push` в Gitea
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чек-лист перед коммитом
|
||||||
|
|
||||||
|
- [ ] `npm run lint` — без ошибок
|
||||||
|
- [ ] `npm run type-check` — без ошибок
|
||||||
|
- [ ] Новые `.env` переменные добавлены в `.env.example`
|
||||||
|
- [ ] Миграция БД согласована (если есть)
|
||||||
|
- [ ] Нет `console.log`, нет секретов в коде
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тестовые аккаунты
|
||||||
|
|
||||||
|
| Email | Пароль | Роль |
|
||||||
|
|-------|--------|------|
|
||||||
|
| admin@second-brain.ru | Password123! | admin |
|
||||||
|
| curator@second-brain.ru | Password123! | curator |
|
||||||
|
| student@second-brain.ru | Password123! | student |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущий статус проекта
|
||||||
|
|
||||||
|
**Завершено (9 из 13 этапов):**
|
||||||
|
- Этап 0: Каркас, auth, роли, деплой
|
||||||
|
- Этап 1: Курсы → Модули → Уроки (CRUD, drag-and-drop, TipTap, S3)
|
||||||
|
- Этап 1.5: Расширенный доступ (сроки, категории, AccessLog)
|
||||||
|
- Этап 2: Kinescope-интеграция, рендер уроков для ученика
|
||||||
|
- Этап 3: Прогресс (кнопка завершения, прогресс-бар)
|
||||||
|
- Этап 5: Домашние задания + обратная связь куратора
|
||||||
|
- Этап 6: Обсуждения под уроками
|
||||||
|
- Этап 7: Email-уведомления (Resend)
|
||||||
|
- Этап 8: Импорт уроков из Markdown (Obsidian)
|
||||||
|
|
||||||
|
**В работе:**
|
||||||
|
- Этап 9: Настройки платформы (Admin Settings)
|
||||||
|
|
||||||
|
**Впереди:**
|
||||||
|
- Этап 11: Импорт/экспорт учеников (CSV, миграция с emdesell)
|
||||||
|
- Этап 12: Telegram-бот + аналитика (Yandex.Metrika)
|
||||||
|
- Этап 13: Тесты и квизы с автопроверкой
|
||||||
|
|
||||||
|
**Бэклог:** сертификаты, геймификация, платежи, медиатека, цифровой сад, CI/CD
|
||||||
|
|
||||||
|
Полный роадмап с деталями и критериями готовности — в `ROADMAP.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- Seed-пользователи вставлены через SQL с `emailVerified = true` (обход Better Auth)
|
||||||
|
- Загрузка файлов: нет лимита на уровне Next.js (только S3)
|
||||||
|
- Drag-and-drop: возможны race conditions при быстрых перетаскиваниях (некритично)
|
||||||
|
- `expiresAt` проверяется в UI, но не блокирует доступ на уровне middleware
|
||||||
|
|||||||
+24
@@ -275,6 +275,30 @@
|
|||||||
|
|
||||||
## Бэклог (после MVP)
|
## Бэклог (после MVP)
|
||||||
|
|
||||||
|
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
|
||||||
|
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
|
||||||
|
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
|
||||||
|
- Embeddable open-source editor (в одну строку) — отложить, пока не требуется
|
||||||
|
- Сейчас Этап 7 (Email-уведомления) завершён на базовой связке, задача — рефакторинг на React Email
|
||||||
|
|
||||||
|
- **Самостоятельная регистрация + автоматический онбординг** — два сценария входа и воронка после регистрации:
|
||||||
|
|
||||||
|
**Сценарии регистрации:**
|
||||||
|
- С лендинга через покупку — пользователь оплачивает курс, аккаунт создаётся автоматически, письмо с доступом приходит сразу
|
||||||
|
- Прямой вход на платформу — пользователь приходит по реферальной ссылке, из соцсетей, от партнёров — регистрируется сам без покупки
|
||||||
|
|
||||||
|
**Автоматический онбординг после регистрации:**
|
||||||
|
- Автоназначение вводных / вотер-модулей курсов (бесплатные превью, чтобы зацепить)
|
||||||
|
- Доступ к базовой библиотеке материалов по умолчанию (статьи, шаблоны, гайды — определяется в настройках)
|
||||||
|
- Приветственная воронка: серия писем / уведомлений, которая ведёт к первой покупке
|
||||||
|
- Уведомление администратора о новой регистрации (email + Telegram)
|
||||||
|
|
||||||
|
**Что нужно проработать:**
|
||||||
|
- Публичная страница регистрации (+ CAPTCHA, опционально)
|
||||||
|
- Настройка в Этапе 9: «Регистрация открыта: да/нет» + выбор вводных курсов/модулей, которые назначаются автоматически
|
||||||
|
- Интеграция с платёжной системой: оплата на лендинге → автосоздание аккаунта → автовыдача доступа к купленному курсу
|
||||||
|
- Разграничение: что видит гость / зарегистрированный без покупки / купивший курс
|
||||||
|
|
||||||
- Резервное копирование PostgreSQL (cron → Object Storage)
|
- Резервное копирование PostgreSQL (cron → Object Storage)
|
||||||
- GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
|
- GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
|
||||||
- Сертификаты по окончании курса
|
- Сертификаты по окончании курса
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "BalanceTransaction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(10,2) NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "BalanceTransaction_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "BalanceTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "BalanceTransaction_userId_idx" ON "BalanceTransaction"("userId");
|
||||||
@@ -36,6 +36,7 @@ model User {
|
|||||||
feedbacks HomeworkFeedback[]
|
feedbacks HomeworkFeedback[]
|
||||||
accessLogs AccessLog[] @relation("AccessLogUser")
|
accessLogs AccessLog[] @relation("AccessLogUser")
|
||||||
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
||||||
|
balanceTransactions BalanceTransaction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@@ -311,6 +312,20 @@ model LessonComment {
|
|||||||
replies LessonComment[] @relation("CommentReplies")
|
replies LessonComment[] @relation("CommentReplies")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Balance
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
model BalanceTransaction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
description String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Platform Settings (key-value store)
|
// Platform Settings (key-value store)
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ export async function updateUserContact(
|
|||||||
revalidatePath(`/admin/users/${userId}`);
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addBalanceTransaction(
|
||||||
|
userId: string,
|
||||||
|
data: { amount: string; description: string }
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
const amount = parseFloat(data.amount.replace(",", "."));
|
||||||
|
if (isNaN(amount) || amount === 0) throw new Error("Некорректная сумма");
|
||||||
|
await prisma.balanceTransaction.create({
|
||||||
|
data: { userId, amount, description: data.description.trim() },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBalanceTransaction(userId: string, txId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.balanceTransaction.delete({ where: { id: txId } });
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeUserAccess(userId: string, courseId: string) {
|
export async function revokeUserAccess(userId: string, courseId: string) {
|
||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
await prisma.courseEnrollment.delete({
|
await prisma.courseEnrollment.delete({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
|
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
|
||||||
import { UserContactEditor } from "@/components/admin/user-contact-editor";
|
import { UserContactEditor } from "@/components/admin/user-contact-editor";
|
||||||
|
import { UserBalanceBlock } from "@/components/admin/user-balance-block";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ userId: string }>;
|
params: Promise<{ userId: string }>;
|
||||||
@@ -27,6 +28,9 @@ export default async function UserPage({ params }: Props) {
|
|||||||
grantedBy: { select: { name: true } },
|
grantedBy: { select: { name: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
balanceTransactions: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.course.findMany({
|
prisma.course.findMany({
|
||||||
@@ -73,6 +77,22 @@ export default async function UserPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Balance */}
|
||||||
|
<section className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Баланс
|
||||||
|
</p>
|
||||||
|
<UserBalanceBlock
|
||||||
|
userId={userId}
|
||||||
|
transactions={user.balanceTransactions.map((tx) => ({
|
||||||
|
id: tx.id,
|
||||||
|
amount: Number(tx.amount),
|
||||||
|
description: tx.description,
|
||||||
|
createdAt: tx.createdAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Enrollments + bulk grant */}
|
{/* Enrollments + bulk grant */}
|
||||||
<section className="card-aubade p-6 mb-6">
|
<section className="card-aubade p-6 mb-6">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { addBalanceTransaction, deleteBalanceTransaction } from "@/app/admin/users/[userId]/actions";
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
transactions: Transaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
outline: "none",
|
||||||
|
padding: "0.4rem 0.6rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const focusHandlers = {
|
||||||
|
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
||||||
|
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--border)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAmount(amount: number): string {
|
||||||
|
const sign = amount > 0 ? "+" : "";
|
||||||
|
return `${sign}${amount.toLocaleString("ru-RU", { minimumFractionDigits: 0, maximumFractionDigits: 2 })} ₽`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserBalanceBlock({ userId, transactions }: Props) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [amountVal, setAmountVal] = useState("");
|
||||||
|
const [descVal, setDescVal] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
setError("");
|
||||||
|
const num = parseFloat(amountVal.replace(",", "."));
|
||||||
|
if (isNaN(num) || num === 0) { setError("Введите ненулевую сумму"); return; }
|
||||||
|
if (!descVal.trim()) { setError("Добавьте описание"); return; }
|
||||||
|
startTransition(async () => {
|
||||||
|
await addBalanceTransaction(userId, { amount: amountVal, description: descVal });
|
||||||
|
setAmountVal("");
|
||||||
|
setDescVal("");
|
||||||
|
setShowForm(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(txId: string) {
|
||||||
|
setDeletingId(txId);
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteBalanceTransaction(userId, txId);
|
||||||
|
setDeletingId(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Balance summary */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ color: balance > 0 ? "#3A6A3A" : balance < 0 ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{formatAmount(balance)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
на балансе
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowForm((v) => !v); setError(""); }}
|
||||||
|
className="btn-aubade btn-aubade-accent px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
{showForm ? "Отмена" : "+ Операция"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="p-4 space-y-3" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="space-y-1" style={{ width: 140 }}>
|
||||||
|
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Сумма, ₽
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={amountVal}
|
||||||
|
onChange={(e) => setAmountVal(e.target.value)}
|
||||||
|
placeholder="-3490 или 1000"
|
||||||
|
style={{ ...inputStyle, width: "100%" }}
|
||||||
|
{...focusHandlers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={descVal}
|
||||||
|
onChange={(e) => setDescVal(e.target.value)}
|
||||||
|
placeholder="Предоплата курса, возврат, партнёрка..."
|
||||||
|
style={{ ...inputStyle, width: "100%" }}
|
||||||
|
{...focusHandlers}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={pending}
|
||||||
|
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
|
||||||
|
style={{ opacity: pending ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{pending ? "Сохранение..." : "Добавить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction list */}
|
||||||
|
{transactions.length > 0 ? (
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<div
|
||||||
|
key={tx.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-xs group"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-bold"
|
||||||
|
style={{
|
||||||
|
minWidth: 80,
|
||||||
|
color: tx.amount > 0 ? "#3A6A3A" : "oklch(0.577 0.245 27.325)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAmount(tx.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">{tx.description}</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)", whiteSpace: "nowrap" }}>
|
||||||
|
{new Date(tx.createdAt).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(tx.id)}
|
||||||
|
disabled={deletingId === tx.id || pending}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||||
|
style={{ color: "oklch(0.577 0.245 27.325)", flexShrink: 0 }}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Операций ещё нет</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user