c94a8dafa9
Переопределены токены шкалы 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>
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|