Files
mkd/components/finance/ReportUploader.tsx
2026-02-04 00:17:04 +05:00

344 lines
14 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};