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:
2026-05-07 09:24:25 +05:00
parent 48721759d3
commit 93e74951a7
8 changed files with 593 additions and 13 deletions
+3
View File
@@ -45,3 +45,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# Claude Code local plugins (external git repos, не коммитим)
.claude/plugins/
+307 -4
View File
@@ -1,5 +1,308 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# AGENTS.md — LMS Second Brain
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.
<!-- END:nextjs-agent-rules -->
Собственная LMS-платформа для образовательных курсов по PKM и Obsidian.
Заменяет 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
View File
@@ -275,6 +275,30 @@
## Бэклог (после 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)
- 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");
+24 -9
View File
@@ -27,15 +27,16 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
enrollments CourseEnrollment[]
progress LessonProgress[]
submissions HomeworkSubmission[]
comments LessonComment[]
feedbacks HomeworkFeedback[]
accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
sessions Session[]
accounts Account[]
enrollments CourseEnrollment[]
progress LessonProgress[]
submissions HomeworkSubmission[]
comments LessonComment[]
feedbacks HomeworkFeedback[]
accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
balanceTransactions BalanceTransaction[]
}
model Session {
@@ -311,6 +312,20 @@ model LessonComment {
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)
// ─────────────────────────────────────────────
+19
View File
@@ -59,6 +59,25 @@ export async function updateUserContact(
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) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
+20
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
import { UserContactEditor } from "@/components/admin/user-contact-editor";
import { UserBalanceBlock } from "@/components/admin/user-balance-block";
interface Props {
params: Promise<{ userId: string }>;
@@ -27,6 +28,9 @@ export default async function UserPage({ params }: Props) {
grantedBy: { select: { name: true } },
},
},
balanceTransactions: {
orderBy: { createdAt: "desc" },
},
},
}),
prisma.course.findMany({
@@ -73,6 +77,22 @@ export default async function UserPage({ params }: Props) {
</div>
</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 */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
+185
View File
@@ -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>
);
}