Files
mkd/components/finance/ReportUploader.tsx

344 lines
14 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};