Files
router-lists-ui/frontend/src/components/ImportModal.jsx
T

248 lines
8.3 KiB
React

import { useEffect, useRef, useState } from 'react';
import { IconUpload, IconFileText, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react';
/**
* ImportModal - модальное окно импорта данных
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function ImportModal({
show,
title = 'Импорт',
description = 'Вставьте текст или перетащите файл TXT/CSV. Формат: VALUE COMMUNITY',
parseLine,
validateItem,
onConfirm,
onClose,
sampleHeader = ['value', 'community'],
placeholder = 'value community\nvalue community',
}) {
const [text, setText] = useState('');
const [dragOver, setDragOver] = useState(false);
const [parsed, setParsed] = useState({ items: [], invalid: [] });
const [fileName, setFileName] = useState('');
const textAreaRef = useRef(null);
useEffect(() => {
if (show) {
setText('');
setParsed({ items: [], invalid: [] });
setFileName('');
setDragOver(false);
setTimeout(() => textAreaRef.current?.focus(), 100);
}
}, [show]);
if (!show) return null;
const parseText = (raw) => {
const lines = String(raw || '')
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean);
const items = [];
const invalid = [];
for (const line of lines) {
const obj = parseLine(line);
if (obj && validateItem(obj)) {
items.push(obj);
} else {
invalid.push(line);
}
}
setParsed({ items, invalid });
};
const handleDrop = async (e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
setFileName(file.name);
const content = await file.text();
setText(content);
parseText(content);
};
const handleChange = (value) => {
setText(value);
parseText(value);
};
const handleConfirm = () => {
if (parsed.items.length === 0) return;
onConfirm?.(parsed.items);
};
const downloadSample = () => {
const csv = [sampleHeader, ['example', '65000:100']]
.map(r => r.map(x => `"${String(x ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sample.csv';
a.click();
URL.revokeObjectURL(url);
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose?.();
}
};
return (
<>
{/* Modal Backdrop */}
<div className="modal-backdrop show" onClick={handleBackdropClick} />
{/* Modal */}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1}>
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
<IconUpload className="me-2" size={24} />
{title}
</h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
</div>
<div className="modal-body">
{/* Description */}
<div className="alert alert-info mb-3">
<div className="d-flex">
<IconAlertCircle className="me-2 flex-shrink-0" size={20} />
<div className="text-muted small">{description}</div>
</div>
</div>
{/* Drop zone */}
<div
className={`card mb-3 ${dragOver ? 'border-primary bg-blue-lt' : 'border-dashed'}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<div className="card-body">
<div className="d-flex align-items-center justify-content-between">
<div className="text-muted">
{fileName ? (
<div className="d-flex align-items-center">
<IconFileText size={20} className="me-2 text-primary" />
<strong>Файл:</strong> {fileName}
</div>
) : (
'Перетащите TXT/CSV сюда или вставьте текст ниже'
)}
</div>
<label className="btn btn-outline-primary mb-0">
<IconUpload size={16} className="me-1" />
Выбрать файл
<input
type="file"
accept=".txt,.csv,.log"
hidden
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setFileName(f.name);
const t = await f.text();
setText(t);
parseText(t);
}}
/>
</label>
</div>
</div>
</div>
{/* Textarea */}
<div className="mb-3">
<textarea
ref={textAreaRef}
className="form-control font-monospace"
rows={8}
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
{/* Stats */}
<div className="row g-2">
<div className="col-md-6">
<div className="card bg-green-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconCheck size={20} className="text-green me-2" />
<div>
<div className="text-muted small">Готово к импорту</div>
<div className="h3 m-0 text-green">{parsed.items.length}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card bg-red-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconX size={20} className="text-red me-2" />
<div>
<div className="text-muted small">Пропущено (ошибки)</div>
<div className="h3 m-0 text-red">{parsed.invalid.length}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{parsed.invalid.length > 0 && (
<div className="alert alert-warning mt-3">
<div className="text-muted small">
<strong>Некорректные строки (показаны первые 5):</strong>
<ul className="mb-0 mt-1">
{parsed.invalid.slice(0, 5).map((line, i) => (
<li key={i}><code>{line}</code></li>
))}
</ul>
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" onClick={downloadSample}>
<IconFileText size={16} className="me-1" />
Скачать шаблон
</button>
<button className="btn" onClick={onClose}>
Отмена
</button>
<button
className="btn btn-primary"
disabled={parsed.items.length === 0}
onClick={handleConfirm}
>
<IconCheck size={16} className="me-1" />
Импортировать ({parsed.items.length})
</button>
</div>
</div>
</div>
</div>
</>
);
}
export default ImportModal;