Files
mkd/components/finance/ReportsGrid.tsx

293 lines
11 KiB
TypeScript
Raw Permalink Normal View History

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