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

293 lines
11 KiB
TypeScript
Executable File
Raw 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, 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>
);
};