From dd46a10c20385f7e3b0c67806e62ae6cb81d6baa Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 8 Apr 2026 12:51:43 +0500 Subject: [PATCH] Add CSV import/export for students (Stage 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import wizard (4 steps): - Upload CSV with UTF-8 or Windows-1251 (iconv-lite) decoding - Auto-detect columns: Email, Имя, Фамилия, Телефон - Preview table with per-row status: new / update / error - Options: auto-verify email, assign course + access days, send welcome email - Apply: creates users with bcrypt password + Account record, grants enrollments Export: - GET /api/admin/export-users with course filter + encoding selection - UTF-8 with BOM (works in all apps) or Windows-1251 (legacy Excel) - Fields: Email, Имя, Телефон, Дата регистрации, Курсы, Прогресс Navigation: added "Импорт / Экспорт" link to admin sidebar Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 1 + package.json | 1 + src/app/admin/import-export/import-actions.ts | 260 ++++++++++++ src/app/admin/import-export/page.tsx | 49 +++ src/app/api/admin/export-users/route.ts | 64 +++ src/components/admin/admin-nav.tsx | 1 + src/components/admin/csv-exporter.tsx | 91 +++++ src/components/admin/csv-importer.tsx | 384 ++++++++++++++++++ 8 files changed, 851 insertions(+) create mode 100644 src/app/admin/import-export/import-actions.ts create mode 100644 src/app/admin/import-export/page.tsx create mode 100644 src/app/api/admin/export-users/route.ts create mode 100644 src/components/admin/csv-exporter.tsx create mode 100644 src/components/admin/csv-importer.tsx diff --git a/package-lock.json b/package-lock.json index 52f5638..6a973d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "gray-matter": "^4.0.3", + "iconv-lite": "^0.7.2", "lucide-react": "^1.7.0", "next": "16.2.2", "next-themes": "^0.4.6", diff --git a/package.json b/package.json index 11c5385..7660cf6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "gray-matter": "^4.0.3", + "iconv-lite": "^0.7.2", "lucide-react": "^1.7.0", "next": "16.2.2", "next-themes": "^0.4.6", diff --git a/src/app/admin/import-export/import-actions.ts b/src/app/admin/import-export/import-actions.ts new file mode 100644 index 0000000..1a45fb0 --- /dev/null +++ b/src/app/admin/import-export/import-actions.ts @@ -0,0 +1,260 @@ +"use server"; + +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import iconv from "iconv-lite"; +import { sendWelcomeEmail } from "@/lib/email"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type ParsedRow = { + index: number; + email: string; + name: string; + lastName: string; + phone: string; + // resolved during preview + status: "new" | "update" | "error"; + errorMsg?: string; + existingId?: string; +}; + +export type PreviewResult = { + rows: ParsedRow[]; + countNew: number; + countUpdate: number; + countError: number; +}; + +export type ImportOptions = { + updateExisting: boolean; + autoVerifyEmail: boolean; + courseId?: string; + accessDays: number; // 0 = unlimited + sendWelcome: boolean; + encoding: "utf8" | "win1251"; +}; + +export type ApplyResult = { + created: number; + updated: number; + errors: string[]; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if ((ch === "," || ch === ";") && !inQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += ch; + } + } + result.push(current.trim()); + return result; +} + +function normalizeHeaders(headers: string[]): Record { + const map: Record = {}; + const aliases: Record = { + email: ["email", "e-mail", "почта", "login", "логин"], + name: ["имя", "name", "firstname", "first_name", "имя пользователя"], + lastName: ["фамилия", "lastname", "last_name", "surname"], + phone: ["телефон", "phone", "tel", "мобильный"], + }; + + headers.forEach((h, i) => { + const lower = h.toLowerCase().replace(/[^a-zа-яё0-9]/gi, ""); + for (const [field, aliasList] of Object.entries(aliases)) { + if (aliasList.some((a) => lower.includes(a.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "")))) { + if (!(field in map)) map[field] = i; + } + } + }); + + return map; +} + +async function assertAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Нет доступа"); + return session; +} + +// ── Parse action ────────────────────────────────────────────────────────────── + +export async function parseCSV( + base64: string, + encoding: "utf8" | "win1251", + updateExisting: boolean +): Promise { + await assertAdmin(); + + // Decode bytes + const buffer = Buffer.from(base64, "base64"); + const text = encoding === "win1251" + ? iconv.decode(buffer, "win1251") + : buffer.toString("utf8"); + + // Split lines (handle \r\n and \n) + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const nonEmpty = lines.filter((l) => l.trim().length > 0); + if (nonEmpty.length < 2) { + return { rows: [], countNew: 0, countUpdate: 0, countError: 0 }; + } + + const headerLine = parseCSVLine(nonEmpty[0]); + const colMap = normalizeHeaders(headerLine); + + if (colMap.email === undefined) { + throw new Error("Не найдена колонка Email. Проверьте заголовки CSV-файла."); + } + + // Load existing emails for fast lookup + const existingUsers = await prisma.user.findMany({ + select: { id: true, email: true }, + }); + const existingByEmail = new Map(existingUsers.map((u) => [u.email.toLowerCase(), u.id])); + + const rows: ParsedRow[] = []; + let countNew = 0, countUpdate = 0, countError = 0; + + for (let i = 1; i < nonEmpty.length; i++) { + const cols = parseCSVLine(nonEmpty[i]); + const email = (cols[colMap.email] ?? "").trim().toLowerCase(); + const name = (cols[colMap.name ?? -1] ?? "").trim(); + const lastName = (cols[colMap.lastName ?? -1] ?? "").trim(); + const phone = (cols[colMap.phone ?? -1] ?? "").trim(); + + const row: ParsedRow = { + index: i, + email, + name: [name, lastName].filter(Boolean).join(" ") || email.split("@")[0], + lastName, + phone, + status: "new", + }; + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + row.status = "error"; + row.errorMsg = "Некорректный email"; + countError++; + } else if (existingByEmail.has(email)) { + row.existingId = existingByEmail.get(email); + if (updateExisting) { + row.status = "update"; + countUpdate++; + } else { + row.status = "error"; + row.errorMsg = "Уже существует (обновление отключено)"; + countError++; + } + } else { + row.status = "new"; + countNew++; + } + + rows.push(row); + } + + return { rows, countNew, countUpdate, countError }; +} + +// ── Apply action ────────────────────────────────────────────────────────────── + +export async function applyImport( + rows: ParsedRow[], + options: ImportOptions +): Promise { + await assertAdmin(); + + let created = 0, updated = 0; + const errors: string[] = []; + + const validRows = rows.filter((r) => r.status !== "error"); + + for (const row of validRows) { + try { + if (row.status === "new") { + // Generate a random password + const rawPassword = Math.random().toString(36).slice(-10) + "A1!"; + const hashedPassword = await bcrypt.hash(rawPassword, 10); + + const user = await prisma.user.create({ + data: { + name: row.name, + email: row.email, + emailVerified: options.autoVerifyEmail, + role: "student", + }, + }); + + await prisma.account.create({ + data: { + userId: user.id, + accountId: user.id, + providerId: "credential", + password: hashedPassword, + }, + }); + + if (options.courseId) { + const expiresAt = options.accessDays > 0 + ? new Date(Date.now() + options.accessDays * 86_400_000) + : null; + await prisma.courseEnrollment.upsert({ + where: { userId_courseId: { userId: user.id, courseId: options.courseId } }, + update: { expiresAt }, + create: { userId: user.id, courseId: options.courseId, expiresAt }, + }); + } + + if (options.sendWelcome) { + await sendWelcomeEmail(user.email, user.name).catch(() => {}); + } + + created++; + } else if (row.status === "update" && row.existingId) { + await prisma.user.update({ + where: { id: row.existingId }, + data: { + name: row.name || undefined, + }, + }); + + if (options.courseId) { + const expiresAt = options.accessDays > 0 + ? new Date(Date.now() + options.accessDays * 86_400_000) + : null; + await prisma.courseEnrollment.upsert({ + where: { userId_courseId: { userId: row.existingId, courseId: options.courseId } }, + update: { expiresAt }, + create: { userId: row.existingId, courseId: options.courseId, expiresAt }, + }); + } + + updated++; + } + } catch (e) { + errors.push(`${row.email}: ${e instanceof Error ? e.message : "Ошибка"}`); + } + } + + return { created, updated, errors }; +} diff --git a/src/app/admin/import-export/page.tsx b/src/app/admin/import-export/page.tsx new file mode 100644 index 0000000..1f13b9b --- /dev/null +++ b/src/app/admin/import-export/page.tsx @@ -0,0 +1,49 @@ +import { prisma } from "@/lib/prisma"; +import { CsvImporter } from "@/components/admin/csv-importer"; +import { CsvExporter } from "@/components/admin/csv-exporter"; + +export const metadata = { title: "Импорт и экспорт" }; + +export default async function ImportExportPage() { + const courses = await prisma.course.findMany({ + orderBy: { title: "asc" }, + select: { id: true, title: true }, + }); + + return ( +
+
+

+ Импорт и экспорт +

+
+ +
+ {/* Import */} +
+

+ Импорт учеников из CSV +

+ +
+ + {/* Export */} +
+

+ Экспорт учеников в CSV +

+ +
+
+
+ ); +} diff --git a/src/app/api/admin/export-users/route.ts b/src/app/api/admin/export-users/route.ts new file mode 100644 index 0000000..0a27a96 --- /dev/null +++ b/src/app/api/admin/export-users/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import iconv from "iconv-lite"; + +export async function GET(request: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") { + return NextResponse.json({ error: "Нет доступа" }, { status: 403 }); + } + + const { searchParams } = request.nextUrl; + const courseId = searchParams.get("courseId") || undefined; + const encoding = (searchParams.get("encoding") as "utf8" | "win1251") ?? "utf8"; + + // Fetch users + const users = await prisma.user.findMany({ + where: courseId + ? { enrollments: { some: { courseId } } } + : { role: "student" }, + orderBy: { createdAt: "desc" }, + include: { + enrollments: { + include: { course: { select: { title: true } } }, + }, + progress: { select: { lessonId: true } }, + }, + }); + + // Build CSV rows + const csvHeaders = ["Email", "Имя", "Телефон", "Дата регистрации", "Курсы", "Прогресс (уроков)"]; + const rows = users.map((u) => { + const courses = u.enrollments.map((e) => e.course.title).join(" | "); + const progress = u.progress.length; + const registeredAt = new Date(u.createdAt).toLocaleDateString("ru-RU"); + return [u.email, u.name, "", registeredAt, courses, String(progress)]; + }); + + const allRows = [csvHeaders, ...rows]; + const csvText = allRows + .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(";")) + .join("\r\n"); + + // Encode + let body: Buffer; + let charset: string; + if (encoding === "win1251") { + body = iconv.encode(csvText, "win1251"); + charset = "windows-1251"; + } else { + body = Buffer.from("\uFEFF" + csvText, "utf8"); // BOM for Excel + charset = "utf-8"; + } + + const filename = `students_${new Date().toISOString().slice(0, 10)}.csv`; + + return new NextResponse(body as unknown as BodyInit, { + headers: { + "Content-Type": `text/csv; charset=${charset}`, + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +} diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index 256b864..bd84589 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/import-export", label: "Импорт / Экспорт" }, { href: "/admin/settings", label: "Настройки" }, ]; diff --git a/src/components/admin/csv-exporter.tsx b/src/components/admin/csv-exporter.tsx new file mode 100644 index 0000000..3123db6 --- /dev/null +++ b/src/components/admin/csv-exporter.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { Download } from "lucide-react"; + +type Course = { id: string; title: string }; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + width: "100%", + padding: "0.5rem 0.75rem", + fontSize: "0.875rem", + fontFamily: "inherit", +}; +const focusHandlers = { + onFocus: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--foreground)"), + onBlur: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--border)"), +}; + +export function CsvExporter({ courses }: { courses: Course[] }) { + const [courseId, setCourseId] = useState(""); + const [encoding, setEncoding] = useState<"utf8" | "win1251">("utf8"); + const [loading, setLoading] = useState(false); + + async function handleExport() { + setLoading(true); + try { + const params = new URLSearchParams({ encoding }); + if (courseId) params.set("courseId", courseId); + + const res = await fetch(`/api/admin/export-users?${params}`); + if (!res.ok) throw new Error("Ошибка сервера"); + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const cd = res.headers.get("content-disposition") ?? ""; + const match = cd.match(/filename="([^"]+)"/); + a.download = match?.[1] ?? "students.csv"; + a.click(); + URL.revokeObjectURL(url); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + +
+ +
+ + +
+ +
+

Поля в файле

+

Email · Имя · Телефон · Дата регистрации · Курсы · Прогресс (уроков)

+
+ + +
+ ); +} diff --git a/src/components/admin/csv-importer.tsx b/src/components/admin/csv-importer.tsx new file mode 100644 index 0000000..953c99e --- /dev/null +++ b/src/components/admin/csv-importer.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState, useTransition, useRef } from "react"; +import { Upload, FileText, CheckCircle, AlertCircle, Loader } from "lucide-react"; +import { + parseCSV, + applyImport, + type PreviewResult, + type ImportOptions, + type ApplyResult, +} from "@/app/admin/import-export/import-actions"; + +type Course = { id: string; title: string }; + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + width: "100%", + padding: "0.5rem 0.75rem", + fontSize: "0.875rem", + fontFamily: "inherit", +}; +const focusHandlers = { + onFocus: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--foreground)"), + onBlur: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--border)"), +}; + +function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( +
+ +
+

{label}

+ {hint &&

{hint}

} +
+
+ ); +} + +function StepIndicator({ step }: { step: number }) { + const steps = ["Загрузка", "Предпросмотр", "Опции", "Готово"]; + return ( +
+ {steps.map((label, i) => { + const num = i + 1; + const active = num === step; + const done = num < step; + return ( +
+
+
+ {done ? "✓" : num} +
+ + {label} + +
+ {i < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function CsvImporter({ courses }: { courses: Course[] }) { + const [step, setStep] = useState(1); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const fileRef = useRef(null); + + // Step 1 state + const [encoding, setEncoding] = useState<"utf8" | "win1251">("utf8"); + const [updateExisting, setUpdateExisting] = useState(false); + const [fileBase64, setFileBase64] = useState(null); + const [fileName, setFileName] = useState(null); + + // Step 2 state + const [preview, setPreview] = useState(null); + + // Step 3 state + const [autoVerifyEmail, setAutoVerifyEmail] = useState(true); + const [courseId, setCourseId] = useState(""); + const [accessDays, setAccessDays] = useState("0"); + const [sendWelcome, setSendWelcome] = useState(false); + + // Step 4 state + const [result, setResult] = useState(null); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setFileName(file.name); + const reader = new FileReader(); + reader.onload = () => { + const ab = reader.result as ArrayBuffer; + const bytes = new Uint8Array(ab); + let binary = ""; + bytes.forEach((b) => (binary += String.fromCharCode(b))); + setFileBase64(btoa(binary)); + }; + reader.readAsArrayBuffer(file); + } + + function handleParse() { + if (!fileBase64) return; + setError(null); + startTransition(async () => { + try { + const result = await parseCSV(fileBase64, encoding, updateExisting); + setPreview(result); + setStep(2); + } catch (e) { + setError(e instanceof Error ? e.message : "Ошибка разбора файла"); + } + }); + } + + function handleApply() { + if (!preview) return; + setError(null); + const options: ImportOptions = { + updateExisting, + autoVerifyEmail, + courseId: courseId || undefined, + accessDays: parseInt(accessDays) || 0, + sendWelcome, + encoding, + }; + startTransition(async () => { + try { + const r = await applyImport(preview.rows, options); + setResult(r); + setStep(4); + } catch (e) { + setError(e instanceof Error ? e.message : "Ошибка импорта"); + } + }); + } + + function handleReset() { + setStep(1); + setFileBase64(null); + setFileName(null); + setPreview(null); + setResult(null); + setError(null); + if (fileRef.current) fileRef.current.value = ""; + } + + return ( +
+ + + {/* ── Step 1: Upload ── */} + {step === 1 && ( +
+ {/* File picker */} +
fileRef.current?.click()} + > + + {fileName ? ( +
+

+ {fileName} +

+

Нажмите чтобы выбрать другой файл

+
+ ) : ( +
+

Выберите CSV-файл

+

Поддерживаются файлы из emdesell, Excel и любого табличного редактора

+
+ )} + +
+ + {/* Options */} +
+
+ + +
+
+ +
+
+ + {/* Template download hint */} +

+ Ожидаемые колонки: Email, Имя, Фамилия, Телефон (порядок не важен, первая строка — заголовки). +

+ + {error &&

{error}

} + + +
+ )} + + {/* ── Step 2: Preview ── */} + {step === 2 && preview && ( +
+ {/* Stats */} +
+ {[ + { label: "Будет создано", count: preview.countNew, color: "#3A6A3A" }, + { label: "Будет обновлено", count: preview.countUpdate, color: "var(--foreground)" }, + { label: "Ошибок", count: preview.countError, color: "oklch(0.577 0.245 27.325)" }, + ].map(({ label, count, color }) => ( +
+

{count}

+

{label}

+
+ ))} +
+ + {/* Table */} +
+ + + + {["#", "Email", "Имя", "Телефон", "Статус"].map((h) => ( + + ))} + + + + {preview.rows.map((row) => ( + + + + + + + + ))} + +
{h}
{row.index}{row.email}{row.name}{row.phone || "—"} + {row.status === "new" && ✦ Новый} + {row.status === "update" && ↻ Обновить} + {row.status === "error" && ( + + {row.errorMsg} + + )} +
+
+ +
+ + +
+
+ )} + + {/* ── Step 3: Options ── */} + {step === 3 && ( +
+ + +
+ + +
+ + {courseId && ( +
+ + setAccessDays(e.target.value)} + placeholder="0 — бессрочно" + style={inputStyle} + {...focusHandlers} + /> +

0 = бессрочный доступ

+
+ )} + + + + {error &&

{error}

} + +
+ + +
+
+ )} + + {/* ── Step 4: Result ── */} + {step === 4 && result && ( +
+
+ +
+

Импорт завершён

+

+ Создано: {result.created} · Обновлено: {result.updated} +

+
+
+ + {result.errors.length > 0 && ( +
+

+ Ошибки ({result.errors.length}) +

+
+ {result.errors.map((e, i) => ( +

{e}

+ ))} +
+
+ )} + + +
+ )} +
+ ); +}