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:
2026-04-08 12:51:43 +05:00
parent 99c143d670
commit dd46a10c20
8 changed files with 851 additions and 0 deletions
+91
View File
@@ -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>
);
}