344 lines
14 KiB
TypeScript
Executable File
344 lines
14 KiB
TypeScript
Executable File
import React, { useState, useRef } from 'react';
|
||
import { Upload, File, X, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||
import { getAuthToken } from '../../services/apiClient';
|
||
|
||
interface ReportUploaderProps {
|
||
onUploadSuccess?: (reportId: number, jobId: string) => void;
|
||
onUploadError?: (error: string) => void;
|
||
onClose?: () => void;
|
||
onViewReports?: () => void;
|
||
presetReportType?: 'salary' | 'balance_sheet_20' | 'balance_sheet_76' | 'balance_sheet' | 'bank_statement' | 'debtors' | 'other';
|
||
reportTypeName?: string;
|
||
}
|
||
|
||
export const ReportUploader: React.FC<ReportUploaderProps> = ({
|
||
onUploadSuccess,
|
||
onUploadError,
|
||
onClose,
|
||
onViewReports,
|
||
presetReportType,
|
||
reportTypeName
|
||
}) => {
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [reportType, setReportType] = useState<'debtors' | 'balance_sheet' | 'salary' | 'balance_sheet_20' | 'balance_sheet_76' | 'bank_statement' | 'other'>(
|
||
presetReportType || 'other'
|
||
);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (file) {
|
||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||
if (ext === 'csv' || ext === 'xlsx' || ext === 'xls') {
|
||
setSelectedFile(file);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
} else {
|
||
setErrorMessage('Неподдерживаемый формат файла. Разрешены только CSV и XLSX');
|
||
setUploadStatus('error');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
const file = event.dataTransfer.files[0];
|
||
if (file) {
|
||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||
if (ext === 'csv' || ext === 'xlsx' || ext === 'xls') {
|
||
setSelectedFile(file);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
} else {
|
||
setErrorMessage('Неподдерживаемый формат файла. Разрешены только CSV и XLSX');
|
||
setUploadStatus('error');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
};
|
||
|
||
const handleRemoveFile = () => {
|
||
setSelectedFile(null);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const handleUpload = async () => {
|
||
if (!selectedFile) return;
|
||
|
||
if (!getAuthToken()) {
|
||
setErrorMessage('Требуется авторизация. Выполните вход в систему.');
|
||
setUploadStatus('error');
|
||
onUploadError?.('Требуется авторизация');
|
||
return;
|
||
}
|
||
|
||
setIsUploading(true);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', selectedFile);
|
||
formData.append('uploadedBy', 'Current User'); // Можно получить из контекста
|
||
formData.append('reportType', getBackendReportType());
|
||
// Отправляем также детальный тип для обработки на бэкенде
|
||
if (reportType === 'balance_sheet_20' || reportType === 'balance_sheet_76' || reportType === 'salary' || reportType === 'bank_statement') {
|
||
formData.append('detailedReportType', reportType);
|
||
}
|
||
|
||
const apiBase = import.meta.env.VITE_API_BASE_URL || '';
|
||
const uploadUrl = apiBase ? `${apiBase.replace(/\/$/, '')}/finance/upload-report` : '/api/finance/upload-report';
|
||
const token = getAuthToken();
|
||
const response = await fetch(uploadUrl, {
|
||
method: 'POST',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}));
|
||
const msg = error.details || error.error || 'Ошибка загрузки файла';
|
||
throw new Error(msg);
|
||
}
|
||
|
||
const result = await response.json();
|
||
setUploadStatus('success');
|
||
|
||
if (onUploadSuccess) {
|
||
onUploadSuccess(result.reportId, result.jobId);
|
||
// Если есть обработка (jobId), не закрываем модальное окно - оно закроется автоматически при переходе к processing
|
||
// Если обработки нет, закрываем через 2 секунды
|
||
if (!result.jobId && onClose) {
|
||
setTimeout(() => {
|
||
handleRemoveFile();
|
||
onClose();
|
||
}, 2000);
|
||
} else {
|
||
// Очищаем файл, но не закрываем модальное окно (будет переход к processing)
|
||
setTimeout(() => {
|
||
handleRemoveFile();
|
||
}, 2000);
|
||
}
|
||
} else {
|
||
// Если нет callback, просто очищаем файл
|
||
setTimeout(() => {
|
||
handleRemoveFile();
|
||
}, 2000);
|
||
}
|
||
|
||
} catch (error: any) {
|
||
setUploadStatus('error');
|
||
setErrorMessage(error.message || 'Произошла ошибка при загрузке файла');
|
||
if (onUploadError) {
|
||
onUploadError(error.message);
|
||
}
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
if (bytes === 0) return '0 Bytes';
|
||
const k = 1024;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||
};
|
||
|
||
// Маппинг типов для отправки на бэкенд
|
||
const getBackendReportType = (): string => {
|
||
if (reportType === 'balance_sheet_20' || reportType === 'balance_sheet_76' || reportType === 'balance_sheet') {
|
||
return 'balance_sheet';
|
||
}
|
||
if (reportType === 'salary' || reportType === 'bank_statement') {
|
||
return 'other'; // Пока что salary и bank_statement идут как 'other', можно расширить позже
|
||
}
|
||
return reportType;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 relative">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-bold text-slate-800">
|
||
{reportTypeName ? `Загрузить: ${reportTypeName}` : 'Загрузить отчеты из 1С / Банка'}
|
||
</h3>
|
||
{onClose && (
|
||
<button
|
||
onClick={onClose}
|
||
className="p-2 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-100"
|
||
title="Закрыть"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Выбор типа отчета (скрыт, если предустановлен) */}
|
||
{!presetReportType && (
|
||
<div className="mb-6">
|
||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||
Тип отчета
|
||
</label>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setReportType('debtors')}
|
||
className={`p-3 rounded-lg border-2 text-sm font-medium transition-all ${
|
||
reportType === 'debtors'
|
||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||
}`}
|
||
>
|
||
Должники
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setReportType('balance_sheet')}
|
||
className={`p-3 rounded-lg border-2 text-sm font-medium transition-all ${
|
||
reportType === 'balance_sheet'
|
||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||
}`}
|
||
>
|
||
Оборотная сальдовая ведомость
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setReportType('other')}
|
||
className={`p-3 rounded-lg border-2 text-sm font-medium transition-all ${
|
||
reportType === 'other'
|
||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||
}`}
|
||
>
|
||
Другие отчеты
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{presetReportType && (
|
||
<div className="mb-6 p-3 bg-primary-50 border border-primary-200 rounded-lg">
|
||
<p className="text-sm text-primary-800">
|
||
<strong>Тип отчета:</strong> {reportTypeName || presetReportType}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Drag & Drop Zone */}
|
||
<div
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all ${
|
||
selectedFile
|
||
? 'border-primary-300 bg-primary-50'
|
||
: 'border-slate-300 hover:border-primary-400 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
{!selectedFile ? (
|
||
<>
|
||
<Upload className="w-12 h-12 mx-auto text-slate-400 mb-4" />
|
||
<p className="text-sm font-medium text-slate-700 mb-2">
|
||
Перетащите файл сюда или нажмите для выбора
|
||
</p>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Поддерживаются форматы: CSV, XLSX (макс. 50 МБ)
|
||
</p>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="bg-primary-600 text-white px-6 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors"
|
||
>
|
||
Выбрать файл
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".csv,.xlsx,.xls"
|
||
onChange={handleFileSelect}
|
||
className="hidden"
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-center gap-3">
|
||
<File className="w-8 h-8 text-primary-600" />
|
||
<div className="text-left">
|
||
<p className="font-bold text-slate-800">{selectedFile.name}</p>
|
||
<p className="text-xs text-slate-500">{formatFileSize(selectedFile.size)}</p>
|
||
</div>
|
||
<button
|
||
onClick={handleRemoveFile}
|
||
className="ml-auto p-2 text-slate-400 hover:text-red-600 transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{uploadStatus === 'success' && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-center gap-2 text-emerald-600">
|
||
<CheckCircle2 className="w-5 h-5" />
|
||
<span className="text-sm font-medium">Файл успешно загружен!</span>
|
||
</div>
|
||
{onViewReports && (
|
||
<button
|
||
onClick={onViewReports}
|
||
className="w-full bg-emerald-600 text-white px-4 py-2 rounded-lg font-medium text-sm hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<FileText className="w-4 h-4" />
|
||
Просмотреть загруженные отчеты
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{uploadStatus === 'error' && (
|
||
<div className="flex items-center justify-center gap-2 text-red-600">
|
||
<AlertCircle className="w-5 h-5" />
|
||
<span className="text-sm font-medium">{errorMessage}</span>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleUpload}
|
||
disabled={isUploading || uploadStatus === 'success'}
|
||
className="w-full bg-primary-600 text-white px-6 py-3 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
>
|
||
{isUploading ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Загрузка...
|
||
</>
|
||
) : uploadStatus === 'success' ? (
|
||
'Загружено'
|
||
) : (
|
||
'Загрузить файл'
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Информация */}
|
||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||
<p className="text-xs text-blue-800">
|
||
<strong>Важно:</strong> Убедитесь, что файл содержит колонку с адресами домов.
|
||
Адреса должны совпадать с адресами домов в системе.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|