293 lines
11 KiB
TypeScript
Executable File
293 lines
11 KiB
TypeScript
Executable File
import React, { useState, useEffect } from 'react';
|
||
import { FileText, Calendar, User, AlertCircle, CheckCircle2, XCircle, Loader2, ArrowRight } from 'lucide-react';
|
||
import { FinancialReport } from '../../types';
|
||
import { authFetch } from '../../services/apiClient';
|
||
import { readCache, saveCache } from '../../hooks/useCachedFetch';
|
||
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
|
||
|
||
interface ReportsGridProps {
|
||
onReportClick: (report: FinancialReport) => void;
|
||
reportTypeFilter?: string;
|
||
onBack?: () => void;
|
||
}
|
||
|
||
const reportTypeLabels: Record<string, string> = {
|
||
debtors: 'Должники',
|
||
balance_sheet: 'Оборотная сальдовая ведомость',
|
||
balance_sheet_76: 'Лицевые счета (ОСВ 76)',
|
||
other: 'Другие отчеты'
|
||
};
|
||
|
||
const reportTypeColors: Record<string, string> = {
|
||
debtors: 'bg-red-50 border-red-200 text-red-700',
|
||
balance_sheet: 'bg-blue-50 border-blue-200 text-blue-700',
|
||
balance_sheet_76: 'bg-violet-50 border-violet-200 text-violet-700',
|
||
other: 'bg-slate-50 border-slate-200 text-slate-700'
|
||
};
|
||
|
||
const CACHE_KEY = 'mkd_finance_reports_cache';
|
||
|
||
export const ReportsGrid: React.FC<ReportsGridProps> = ({
|
||
onReportClick,
|
||
reportTypeFilter,
|
||
onBack
|
||
}) => {
|
||
const [filterType, setFilterType] = useState<string | null>(reportTypeFilter || null);
|
||
const cached = readCache<FinancialReport[]>(CACHE_KEY, []);
|
||
const [reports, setReports] = useState<FinancialReport[]>(cached);
|
||
const [loading, setLoading] = useState(cached.length === 0);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (reportTypeFilter) setFilterType(reportTypeFilter);
|
||
}, [reportTypeFilter]);
|
||
|
||
useEffect(() => {
|
||
fetchReports();
|
||
}, [filterType]);
|
||
|
||
useEffect(() => {
|
||
const onRefresh = () => fetchReports(false);
|
||
window.addEventListener(REFRESH_EVENTS.financeReports, onRefresh);
|
||
return () => window.removeEventListener(REFRESH_EVENTS.financeReports, onRefresh);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => fetchReports(false), 10 * 1000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const fetchReports = async (showSpinner = true) => {
|
||
try {
|
||
const c = readCache<FinancialReport[]>(CACHE_KEY, []);
|
||
if (showSpinner && c.length === 0) setLoading(true);
|
||
setError(null);
|
||
const url = filterType
|
||
? `/api/finance/reports?reportType=${encodeURIComponent(filterType)}`
|
||
: '/api/finance/reports';
|
||
|
||
console.log('Загрузка отчетов:', url);
|
||
const response = await authFetch(url);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
|
||
console.error('Ошибка ответа сервера:', response.status, errorData);
|
||
throw new Error(errorData.error || `Ошибка ${response.status}: Не удалось загрузить отчеты`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('Получены отчеты:', data);
|
||
|
||
// Преобразуем snake_case из API в camelCase для TypeScript
|
||
const formattedReports: FinancialReport[] = data.map((r: any) => ({
|
||
id: r.id,
|
||
filename: r.filename,
|
||
fileType: r.file_type,
|
||
reportType: r.report_type,
|
||
uploadedAt: r.uploaded_at,
|
||
uploadedBy: r.uploaded_by,
|
||
status: r.status,
|
||
totalRows: r.total_rows,
|
||
processedRows: r.processed_rows,
|
||
errorRows: r.error_rows,
|
||
mappingId: r.mapping_id
|
||
}));
|
||
setReports(formattedReports);
|
||
saveCache(CACHE_KEY, formattedReports);
|
||
setError(null);
|
||
} catch (err: any) {
|
||
console.error('Ошибка загрузки отчетов:', err);
|
||
setError(err.message || 'Не удалось загрузить отчеты');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const getStatusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'completed':
|
||
return <CheckCircle2 className="w-5 h-5 text-emerald-600" />;
|
||
case 'failed':
|
||
return <XCircle className="w-5 h-5 text-red-600" />;
|
||
case 'processing':
|
||
return <Loader2 className="w-5 h-5 text-primary-600 animate-spin" />;
|
||
default:
|
||
return <AlertCircle className="w-5 h-5 text-amber-600" />;
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateString: string) => {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
|
||
<p className="text-slate-600">Загрузка отчетов...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="bg-white rounded-2xl border border-red-200 shadow-sm p-6">
|
||
<div className="flex items-center gap-2 text-red-600">
|
||
<AlertCircle className="w-5 h-5" />
|
||
<p className="font-medium">Ошибка: {error}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Заголовок и кнопка назад */}
|
||
{(onBack || reportTypeFilter) && (
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-bold text-slate-800">
|
||
{reportTypeFilter ? reportTypeLabels[reportTypeFilter] : 'Загруженные отчеты'}
|
||
</h3>
|
||
{onBack && (
|
||
<button
|
||
onClick={onBack}
|
||
className="text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
|
||
>
|
||
← Назад к типам отчетов
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Фильтры */}
|
||
{!reportTypeFilter && (
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
onClick={() => setFilterType(null)}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
filterType === null
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
Все отчеты
|
||
</button>
|
||
<button
|
||
onClick={() => setFilterType('debtors')}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
filterType === 'debtors'
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
Должники
|
||
</button>
|
||
<button
|
||
onClick={() => setFilterType('balance_sheet')}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
filterType === 'balance_sheet'
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
Оборотная сальдовая ведомость
|
||
</button>
|
||
<button
|
||
onClick={() => setFilterType('balance_sheet_76')}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
filterType === 'balance_sheet_76'
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
Лицевые счета (ОСВ 76)
|
||
</button>
|
||
<button
|
||
onClick={() => setFilterType('other')}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
filterType === 'other'
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
Другие
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Сетка отчетов */}
|
||
{reports.length === 0 ? (
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||
<FileText className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||
<p className="text-slate-600 font-medium mb-2">Нет загруженных отчетов</p>
|
||
<p className="text-sm text-slate-500">
|
||
Загрузите первый отчет на вкладке "Загрузить отчеты из 1С"
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{reports.map((report) => (
|
||
<button
|
||
key={report.id}
|
||
onClick={() => onReportClick(report)}
|
||
className="bg-white rounded-xl border-2 border-slate-200 hover:border-primary-400 hover:shadow-lg transition-all p-5 text-left group"
|
||
>
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<FileText className="w-5 h-5 text-primary-600" />
|
||
<span className={`px-2 py-1 rounded text-xs font-bold ${
|
||
reportTypeColors[report.reportType || 'other']
|
||
}`}>
|
||
{reportTypeLabels[report.reportType || 'other']}
|
||
</span>
|
||
</div>
|
||
{getStatusIcon(report.status)}
|
||
</div>
|
||
|
||
<h4 className="font-bold text-slate-800 mb-2 line-clamp-2 group-hover:text-primary-600 transition-colors">
|
||
{report.filename}
|
||
</h4>
|
||
|
||
<div className="space-y-2 text-xs text-slate-600 mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<Calendar className="w-4 h-4" />
|
||
<span>{formatDate(report.uploadedAt)}</span>
|
||
</div>
|
||
{report.uploadedBy && (
|
||
<div className="flex items-center gap-2">
|
||
<User className="w-4 h-4" />
|
||
<span>{report.uploadedBy}</span>
|
||
</div>
|
||
)}
|
||
{report.totalRows && (
|
||
<div>
|
||
<span className="font-medium">Строк:</span> {report.totalRows} |
|
||
<span className="text-emerald-600 font-medium ml-1">Обработано:</span> {report.processedRows}
|
||
{report.errorRows > 0 && (
|
||
<span className="text-red-600 font-medium ml-1">Ошибок: {report.errorRows}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-primary-600 font-medium text-sm group-hover:gap-3 transition-all">
|
||
<span>Открыть</span>
|
||
<ArrowRight className="w-4 h-4" />
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|