Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
248 lines
8.3 KiB
React
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;
|