Files
lms-sb/src/components/admin/csv-importer.tsx
T
admins c94a8dafa9 style(lms): синхронизировать типографику со шкалой ДС-2 (+2px)
Переопределены токены шкалы Tailwind (--text-xs…--text-5xl) на +2px,
базовый размер body 18px, размеры компонентных классов (.btn-aubade,
.tag-aubade, .admin-sidebar-nav-link) и инлайновые fontSize приведены
к канону дизайн-системы ДС-2. Rem-база (html 16px) не тронута —
спейсинг и сетка не затронуты, растёт только текст.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:14:16 +05:00

385 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: "16px",
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>
);
}