Add CSV import/export for students (Stage 11)
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -30,6 +30,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -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<string, number> {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
const aliases: Record<string, string[]> = {
|
||||||
|
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<PreviewResult> {
|
||||||
|
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<ApplyResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="p-8 max-w-3xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Импорт и экспорт
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Import */}
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Импорт учеников из CSV
|
||||||
|
</p>
|
||||||
|
<CsvImporter courses={courses} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Экспорт учеников в CSV
|
||||||
|
</p>
|
||||||
|
<CsvExporter courses={courses} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const links = [
|
|||||||
{ href: "/admin/categories", label: "Категории" },
|
{ href: "/admin/categories", label: "Категории" },
|
||||||
{ href: "/admin/users", label: "Пользователи" },
|
{ href: "/admin/users", label: "Пользователи" },
|
||||||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||||||
|
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
||||||
{ href: "/admin/settings", label: "Настройки" },
|
{ href: "/admin/settings", label: "Настройки" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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<HTMLSelectElement>) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
||||||
|
onBlur: (e: React.FocusEvent<HTMLSelectElement>) =>
|
||||||
|
(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 (
|
||||||
|
<div className="space-y-5 max-w-md">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Фильтр по курсу
|
||||||
|
</label>
|
||||||
|
<select value={courseId} onChange={(e) => setCourseId(e.target.value)} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
|
||||||
|
<option value="">Все ученики</option>
|
||||||
|
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Кодировка файла
|
||||||
|
</label>
|
||||||
|
<select value={encoding} onChange={(e) => setEncoding(e.target.value as "utf8" | "win1251")} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
|
||||||
|
<option value="utf8">UTF-8 (универсальная)</option>
|
||||||
|
<option value="win1251">Windows-1251 (для Excel на Windows)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-1" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Поля в файле</p>
|
||||||
|
<p className="text-sm">Email · Имя · Телефон · Дата регистрации · Курсы · Прогресс (уроков)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-aubade btn-aubade-accent flex items-center gap-2 px-5 py-2 text-sm"
|
||||||
|
style={{ opacity: loading ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
{loading ? "Формирую файл..." : "Скачать CSV"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement | HTMLSelectElement>) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
||||||
|
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--border)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button type="button" onClick={() => onChange(!checked)} className="mt-0.5 shrink-0">
|
||||||
|
<span className="relative inline-block w-10 h-6" style={{ background: checked ? "var(--accent)" : "var(--border)", border: "2px solid var(--foreground)" }}>
|
||||||
|
<span className="absolute top-0.5 w-4 h-4 transition-transform" style={{ background: "var(--foreground)", left: "2px", transform: checked ? "translateX(16px)" : "translateX(0)" }} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
{hint && <p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>{hint}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ step }: { step: number }) {
|
||||||
|
const steps = ["Загрузка", "Предпросмотр", "Опции", "Готово"];
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0 mb-6">
|
||||||
|
{steps.map((label, i) => {
|
||||||
|
const num = i + 1;
|
||||||
|
const active = num === step;
|
||||||
|
const done = num < step;
|
||||||
|
return (
|
||||||
|
<div key={num} className="flex items-center">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 flex items-center justify-center text-xs font-bold"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
background: done || active ? "var(--foreground)" : "transparent",
|
||||||
|
color: done || active ? "var(--background)" : "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? "✓" : num}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: active ? "var(--foreground)" : "var(--muted-foreground)", fontWeight: active ? 700 : 400 }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<div className="w-12 h-0.5 mx-1 mb-5" style={{ background: done ? "var(--foreground)" : "var(--border)" }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CsvImporter({ courses }: { courses: Course[] }) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Step 1 state
|
||||||
|
const [encoding, setEncoding] = useState<"utf8" | "win1251">("utf8");
|
||||||
|
const [updateExisting, setUpdateExisting] = useState(false);
|
||||||
|
const [fileBase64, setFileBase64] = useState<string | null>(null);
|
||||||
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 2 state
|
||||||
|
const [preview, setPreview] = useState<PreviewResult | null>(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<ApplyResult | null>(null);
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<StepIndicator step={step} />
|
||||||
|
|
||||||
|
{/* ── Step 1: Upload ── */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* File picker */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-3 p-8 cursor-pointer"
|
||||||
|
style={{ border: "2px dashed var(--border)" }}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload size={28} style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
{fileName ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium flex items-center gap-1.5">
|
||||||
|
<FileText size={15} /> {fileName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>Нажмите чтобы выбрать другой файл</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium">Выберите CSV-файл</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>Поддерживаются файлы из emdesell, Excel и любого табличного редактора</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Кодировка</label>
|
||||||
|
<select value={encoding} onChange={(e) => setEncoding(e.target.value as "utf8" | "win1251")} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
|
||||||
|
<option value="utf8">UTF-8 (стандарт)</option>
|
||||||
|
<option value="win1251">Windows-1251 (Excel)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-0.5">
|
||||||
|
<Toggle
|
||||||
|
label="Обновлять существующих"
|
||||||
|
hint="Если пользователь уже есть — обновить данные"
|
||||||
|
checked={updateExisting}
|
||||||
|
onChange={setUpdateExisting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template download hint */}
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ожидаемые колонки: <span className="font-mono">Email</span>, <span className="font-mono">Имя</span>, <span className="font-mono">Фамилия</span>, <span className="font-mono">Телефон</span> (порядок не важен, первая строка — заголовки).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm p-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!fileBase64 || pending}
|
||||||
|
onClick={handleParse}
|
||||||
|
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm flex items-center gap-2"
|
||||||
|
style={{ opacity: !fileBase64 || pending ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{pending ? <><Loader size={14} className="animate-spin" /> Разбираю...</> : "Далее →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Preview ── */}
|
||||||
|
{step === 2 && preview && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={label} className="card-aubade p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold" style={{ color }}>{count}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-auto max-h-72" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "2px solid var(--border)", background: "var(--background)" }}>
|
||||||
|
{["#", "Email", "Имя", "Телефон", "Статус"].map((h) => (
|
||||||
|
<th key={h} className="text-left px-3 py-2 text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.rows.map((row) => (
|
||||||
|
<tr key={row.index} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<td className="px-3 py-1.5 text-xs" style={{ color: "var(--muted-foreground)" }}>{row.index}</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs">{row.email}</td>
|
||||||
|
<td className="px-3 py-1.5 text-xs">{row.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-xs">{row.phone || "—"}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{row.status === "new" && <span className="text-xs font-bold" style={{ color: "#3A6A3A" }}>✦ Новый</span>}
|
||||||
|
{row.status === "update" && <span className="text-xs font-bold">↻ Обновить</span>}
|
||||||
|
{row.status === "error" && (
|
||||||
|
<span className="text-xs font-bold flex items-center gap-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
<AlertCircle size={12} /> {row.errorMsg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={() => setStep(3)} disabled={preview.countNew + preview.countUpdate === 0}
|
||||||
|
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
|
||||||
|
style={{ opacity: preview.countNew + preview.countUpdate === 0 ? 0.4 : 1 }}>
|
||||||
|
Далее →
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleReset} className="btn-aubade px-4 py-2 text-sm">← Назад</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Options ── */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-5 max-w-md">
|
||||||
|
<Toggle
|
||||||
|
label="Подтвердить email автоматически"
|
||||||
|
hint="Пользователи смогут войти сразу, без подтверждения почты."
|
||||||
|
checked={autoVerifyEmail}
|
||||||
|
onChange={setAutoVerifyEmail}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Курс для доступа</label>
|
||||||
|
<select value={courseId} onChange={(e) => setCourseId(e.target.value)} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
|
||||||
|
<option value="">— Не присваивать —</option>
|
||||||
|
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courseId && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Срок доступа (дней)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={accessDays}
|
||||||
|
onChange={(e) => setAccessDays(e.target.value)}
|
||||||
|
placeholder="0 — бессрочно"
|
||||||
|
style={inputStyle}
|
||||||
|
{...focusHandlers}
|
||||||
|
/>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>0 = бессрочный доступ</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
label="Отправить приветственное письмо"
|
||||||
|
hint="Письмо будет отправлено каждому новому пользователю."
|
||||||
|
checked={sendWelcome}
|
||||||
|
onChange={setSendWelcome}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-sm p-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="button" onClick={handleApply} disabled={pending}
|
||||||
|
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm flex items-center gap-2"
|
||||||
|
style={{ opacity: pending ? 0.5 : 1 }}>
|
||||||
|
{pending ? <><Loader size={14} className="animate-spin" /> Импортирую...</> : "Применить импорт"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setStep(2)} className="btn-aubade px-4 py-2 text-sm">← Назад</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 4: Result ── */}
|
||||||
|
{step === 4 && result && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3 p-5" style={{ border: "2px solid #3A6A3A" }}>
|
||||||
|
<CheckCircle size={24} style={{ color: "#3A6A3A", flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Импорт завершён</p>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Создано: <strong>{result.created}</strong> · Обновлено: <strong>{result.updated}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
Ошибки ({result.errors.length})
|
||||||
|
</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{result.errors.map((e, i) => (
|
||||||
|
<p key={i} className="text-xs font-mono p-2" style={{ border: "1px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" onClick={handleReset} className="btn-aubade px-4 py-2 text-sm">
|
||||||
|
Импортировать ещё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user