Initial commit MKD fixes
This commit is contained in:
343
components/finance/ReportUploader.tsx
Executable file
343
components/finance/ReportUploader.tsx
Executable file
@@ -0,0 +1,343 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user