Initial commit MKD fixes
This commit is contained in:
859
components/finance/ReportDetailView.tsx
Executable file
859
components/finance/ReportDetailView.tsx
Executable file
@@ -0,0 +1,859 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Building2, Grid, List, Home, MapPin, DollarSign, TrendingUp, TrendingDown, FileText, Loader2, CheckCircle2, Eye } from 'lucide-react';
|
||||
import { FinancialReport } from '../../types';
|
||||
import { apiClient, authFetch } from '../../services/apiClient';
|
||||
import { AggregatedReportView } from './AggregatedReportView';
|
||||
import { ResidentReportView } from '../pr/ResidentReportView';
|
||||
|
||||
interface Building {
|
||||
id: string;
|
||||
address: string;
|
||||
district_id?: string;
|
||||
district_name?: string;
|
||||
total_income: number;
|
||||
total_expenses: number;
|
||||
balance: number;
|
||||
reports_count: number;
|
||||
}
|
||||
|
||||
interface ReportDetailViewProps {
|
||||
report: FinancialReport;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'all' | 'sections' | 'specific';
|
||||
type DisplayMode = 'grid' | 'list';
|
||||
|
||||
export const ReportDetailView: React.FC<ReportDetailViewProps> = ({ report, onBack }) => {
|
||||
const [buildings, setBuildings] = useState<Building[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('all');
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('grid');
|
||||
const [selectedBuilding, setSelectedBuilding] = useState<string | null>(null);
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
|
||||
const [sections, setSections] = useState<Record<string, Building[]>>({});
|
||||
const [residentReportIds, setResidentReportIds] = useState<Record<string, number>>({});
|
||||
const [aggregatedData, setAggregatedData] = useState<any>(null);
|
||||
const [districtAggregatedData, setDistrictAggregatedData] = useState<any>(null);
|
||||
const [selectedBuildingReport, setSelectedBuildingReport] = useState<any>(null);
|
||||
const [buildingAggregatedData, setBuildingAggregatedData] = useState<any>(null);
|
||||
const [loadingAggregated, setLoadingAggregated] = useState(false);
|
||||
const [selectedPeriodStart, setSelectedPeriodStart] = useState<string>('');
|
||||
const [selectedPeriodEnd, setSelectedPeriodEnd] = useState<string>('');
|
||||
const [buildingReportViewMode, setBuildingReportViewMode] = useState<'aggregated' | 'detailed'>('detailed');
|
||||
const [report76Rows, setReport76Rows] = useState<any[]>([]);
|
||||
const [loading76Rows, setLoading76Rows] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (report.reportType === 'balance_sheet_76') {
|
||||
setLoading(true);
|
||||
fetchReport76Rows();
|
||||
} else {
|
||||
fetchBuildings();
|
||||
if (report.reportType === 'balance_sheet') {
|
||||
fetchResidentReportIds();
|
||||
}
|
||||
}
|
||||
}, [report.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'all' && report.reportType === 'balance_sheet') {
|
||||
fetchAggregatedData();
|
||||
} else {
|
||||
setAggregatedData(null);
|
||||
}
|
||||
}, [viewMode, report.id, selectedPeriodStart, selectedPeriodEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDistrict && report.reportType === 'balance_sheet') {
|
||||
fetchDistrictAggregatedData(selectedDistrict);
|
||||
} else {
|
||||
setDistrictAggregatedData(null);
|
||||
}
|
||||
}, [selectedDistrict, report.id, selectedPeriodStart, selectedPeriodEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBuilding && report.reportType === 'balance_sheet') {
|
||||
if (buildingReportViewMode === 'aggregated') {
|
||||
fetchBuildingAggregatedReport(selectedBuilding);
|
||||
} else {
|
||||
fetchBuildingReport(selectedBuilding);
|
||||
}
|
||||
} else {
|
||||
setBuildingAggregatedData(null);
|
||||
setSelectedBuildingReport(null);
|
||||
}
|
||||
}, [selectedBuilding, selectedPeriodStart, selectedPeriodEnd, buildingReportViewMode]);
|
||||
|
||||
const fetchResidentReportIds = async () => {
|
||||
try {
|
||||
// Получаем период из ведомости
|
||||
const buildingsData = await authFetch(`/api/finance/buildings?reportId=${report.id}`).then(r => r.json());
|
||||
if (!buildingsData || buildingsData.length === 0) return;
|
||||
|
||||
// Берем период из первого дома
|
||||
const firstBuilding = buildingsData[0];
|
||||
const periodStart = firstBuilding.period_start;
|
||||
const periodEnd = firstBuilding.period_end;
|
||||
|
||||
const reportIds: Record<string, number> = {};
|
||||
|
||||
// Получаем все отчеты собственников
|
||||
const allReports = await apiClient.get<any[]>(`/api/pr/reports`);
|
||||
|
||||
for (const building of buildingsData) {
|
||||
// Ищем отчет для этого дома и периода
|
||||
const matchingReport = allReports.find((r: any) =>
|
||||
r.buildingId === building.id &&
|
||||
r.periodStart === periodStart &&
|
||||
r.periodEnd === periodEnd
|
||||
);
|
||||
|
||||
if (matchingReport) {
|
||||
reportIds[building.id] = matchingReport.id;
|
||||
}
|
||||
}
|
||||
|
||||
setResidentReportIds(reportIds);
|
||||
} catch (err) {
|
||||
console.warn('Не удалось загрузить ID отчетов собственников:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (buildings.length > 0) {
|
||||
// Группируем дома по участкам
|
||||
const grouped: Record<string, Building[]> = {};
|
||||
buildings.forEach(building => {
|
||||
const section = building.district_name || building.district_id || 'Без участка';
|
||||
if (!grouped[section]) {
|
||||
grouped[section] = [];
|
||||
}
|
||||
grouped[section].push(building);
|
||||
});
|
||||
setSections(grouped);
|
||||
}
|
||||
}, [buildings]);
|
||||
|
||||
const fetchReport76Rows = async () => {
|
||||
try {
|
||||
setLoading76Rows(true);
|
||||
const response = await authFetch(`/api/finance/reports/${report.id}/balance-sheet-76-rows`);
|
||||
if (!response.ok) throw new Error('Не удалось загрузить строки');
|
||||
const data = await response.json();
|
||||
setReport76Rows(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка загрузки строк ОСВ 76:', err);
|
||||
setReport76Rows([]);
|
||||
} finally {
|
||||
setLoading76Rows(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authFetch(`/api/finance/buildings?reportId=${report.id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить дома');
|
||||
}
|
||||
const data = await response.json();
|
||||
// Преобразуем snake_case из API в camelCase
|
||||
const formattedBuildings: Building[] = data.map((b: any) => {
|
||||
// Используем district_name, если он есть и не равен district_id, иначе используем district_id
|
||||
const districtName = b.district_name && b.district_name !== b.district_id
|
||||
? b.district_name
|
||||
: (b.district_id || undefined);
|
||||
|
||||
// Логируем для отладки
|
||||
if (b.district_id && !b.district_name) {
|
||||
console.warn(`[ReportDetailView] Участок ${b.district_id} не найден в таблице districts`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
address: b.address,
|
||||
district_id: b.district_id,
|
||||
district_name: districtName,
|
||||
total_income: parseFloat(b.total_income) || 0,
|
||||
total_expenses: parseFloat(b.total_expenses) || 0,
|
||||
balance: parseFloat(b.balance) || 0,
|
||||
reports_count: parseInt(b.reports_count) || 0
|
||||
};
|
||||
});
|
||||
setBuildings(formattedBuildings);
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка загрузки домов:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAggregatedData = async () => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
const params = new URLSearchParams();
|
||||
// Фильтруем по периоду только если период выбран
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/aggregated${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
// Если ошибка 404, пробуем загрузить без фильтра по периоду
|
||||
if (response.status === 404 && (selectedPeriodStart || selectedPeriodEnd)) {
|
||||
const fallbackUrl = `/api/finance/reports/${report.id}/aggregated`;
|
||||
const fallbackResponse = await fetch(fallbackUrl);
|
||||
if (fallbackResponse.ok) {
|
||||
const data = await fallbackResponse.json();
|
||||
// Инициализируем период из данных
|
||||
if (data.periodStart && data.periodEnd) {
|
||||
setSelectedPeriodStart(data.periodStart);
|
||||
setSelectedPeriodEnd(data.periodEnd);
|
||||
}
|
||||
setAggregatedData({
|
||||
...data,
|
||||
title: 'Отчет по всем домам',
|
||||
subtitle: `Всего домов: ${data.buildingsCount}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('Не удалось загрузить агрегированные данные');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Инициализируем период из данных, если период еще не установлен
|
||||
if (!selectedPeriodStart && !selectedPeriodEnd && data.periodStart && data.periodEnd) {
|
||||
setSelectedPeriodStart(data.periodStart);
|
||||
setSelectedPeriodEnd(data.periodEnd);
|
||||
}
|
||||
|
||||
setAggregatedData({
|
||||
...data,
|
||||
title: 'Отчет по всем домам',
|
||||
subtitle: `Всего домов: ${data.buildingsCount}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки агрегированных данных:', err);
|
||||
setAggregatedData(null);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDistrictAggregatedData = async (districtId: string) => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/districts/${districtId}/aggregated${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Не удалось загрузить данные по участку');
|
||||
const data = await response.json();
|
||||
setDistrictAggregatedData({
|
||||
...data,
|
||||
title: `Отчет по участку: ${data.districtName}`,
|
||||
subtitle: `Домов в участке: ${data.buildingsCount}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки данных по участку:', err);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildingReport = async (buildingId: string) => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
|
||||
// Сначала пробуем загрузить существующий отчет из resident_reports
|
||||
const reportId = residentReportIds[buildingId];
|
||||
if (reportId) {
|
||||
try {
|
||||
const response = await fetch(`/api/pr/reports/${reportId}`);
|
||||
if (response.ok) {
|
||||
const reportData = await response.json();
|
||||
setSelectedBuildingReport(reportData);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Не удалось загрузить существующий отчет, генерируем на лету:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Если отчета нет или не удалось загрузить, генерируем на лету из данных ведомости
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/buildings/${buildingId}/report${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить или сгенерировать отчет');
|
||||
}
|
||||
const reportData = await response.json();
|
||||
setSelectedBuildingReport(reportData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки отчета по дому:', err);
|
||||
setSelectedBuildingReport(null);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildingAggregatedReport = async (buildingId: string) => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
|
||||
// Загружаем агрегированный отчет по дому
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/buildings/${buildingId}/aggregated${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить отчет по дому');
|
||||
}
|
||||
const reportData = await response.json();
|
||||
setBuildingAggregatedData(reportData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки агрегированного отчета по дому:', err);
|
||||
setBuildingAggregatedData(null);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const renderBuildingCard = (building: Building, onClick?: () => void) => {
|
||||
const CardContent = (
|
||||
<div className={`bg-white rounded-xl border-2 ${
|
||||
selectedBuilding === building.id
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-slate-200 hover:border-primary-400'
|
||||
} p-5 transition-all ${onClick ? 'cursor-pointer hover:shadow-lg' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Building2 className="w-5 h-5 text-primary-600 flex-shrink-0" />
|
||||
<h4 className="font-bold text-slate-800 line-clamp-2">{building.address}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
Доходы:
|
||||
</span>
|
||||
<span className="font-bold text-emerald-600">
|
||||
{formatCurrency(building.total_income)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||
Расходы:
|
||||
</span>
|
||||
<span className="font-bold text-red-600">
|
||||
{formatCurrency(building.total_expenses)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-200">
|
||||
<span className="text-slate-700 font-medium flex items-center gap-1">
|
||||
<DollarSign className="w-4 h-4 text-primary-600" />
|
||||
Баланс:
|
||||
</span>
|
||||
<span className={`font-bold text-lg ${
|
||||
building.balance >= 0 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(building.balance)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная статистика */}
|
||||
<div className="pt-2 border-t border-slate-100 space-y-1">
|
||||
{building.reports_count > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Отчетов создано:</span>
|
||||
<span className="font-medium text-slate-700">{building.reports_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{building.total_expenses > 0 && building.total_income > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Рентабельность:</span>
|
||||
<span className={`font-medium ${
|
||||
(building.total_income / building.total_expenses) >= 1 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{Math.round((building.total_income / building.total_expenses) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{building.district_name && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500 mb-3">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>Участок: {building.district_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка просмотра отчета собственника (если есть) */}
|
||||
{report.reportType === 'balance_sheet' && residentReportIds[building.id] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Открываем отчет в новой вкладке
|
||||
const url = `/pr/reports/${residentReportIds[building.id]}`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
className="mt-3 w-full bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-primary-700 transition-colors text-sm font-medium"
|
||||
title="Открыть отчет собственникам МКД для этого дома"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Отчет собственникам
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div key={building.id} onClick={onClick}>
|
||||
{CardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={building.id}>{CardContent}</div>;
|
||||
};
|
||||
|
||||
if (loading && report.reportType !== 'balance_sheet_76') {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Загрузка данных...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (report.reportType === 'balance_sheet_76') {
|
||||
if (loading76Rows) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Загрузка лицевых счетов...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const formatNum = (n: number) => (n == null || Number.isNaN(n) ? '—' : Number(n).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
|
||||
<button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-lg transition-colors flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-1 break-words">{report.filename}</h2>
|
||||
<p className="text-sm text-slate-500">Лицевые счета: {report76Rows.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 font-semibold text-slate-700">Лицевой счёт</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Сальдо на начало</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Обороты дебет</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Обороты кредит</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Сальдо на конец (дебет)</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Сальдо на конец (кредит)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report76Rows.map((row: any) => (
|
||||
<tr key={row.id ?? row.rowIndex} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-2 px-4 text-slate-800">{row.accountLabel ?? '—'}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.saldoStartDebet)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.turnoverDebet)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.turnoverCredit)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.saldoEndDebet)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.saldoEndCredit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{report76Rows.length === 0 && (
|
||||
<div className="p-12 text-center text-slate-500">Нет данных по лицевым счетам</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Заголовок и навигация */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-1 break-words">{report.filename}</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{buildings.length} {buildings.length === 1 ? 'дом' : buildings.length < 5 ? 'дома' : 'домов'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор периода */}
|
||||
{report.reportType === 'balance_sheet' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<label className="text-sm font-medium text-slate-700 whitespace-nowrap">Период отчета:</label>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-600 whitespace-nowrap">С:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedPeriodStart}
|
||||
onChange={(e) => setSelectedPeriodStart(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-600 whitespace-nowrap">По:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedPeriodEnd}
|
||||
onChange={(e) => setSelectedPeriodEnd(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('all');
|
||||
setSelectedBuilding(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
viewMode === 'all'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700 font-bold'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Grid className="w-6 h-6" />
|
||||
<span className="text-sm">Все дома</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('sections');
|
||||
setSelectedBuilding(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
viewMode === 'sections'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700 font-bold'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<List className="w-6 h-6" />
|
||||
<span className="text-sm">По участкам</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('specific');
|
||||
setSelectedBuilding(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
viewMode === 'specific'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700 font-bold'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Home className="w-6 h-6" />
|
||||
<span className="text-sm">Конкретный дом</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Переключатель вида отображения */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDisplayMode('grid')}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
displayMode === 'grid'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDisplayMode('list')}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
displayMode === 'list'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент в зависимости от режима */}
|
||||
{viewMode === 'all' && (
|
||||
<>
|
||||
{loadingAggregated ? (
|
||||
<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>
|
||||
) : aggregatedData ? (
|
||||
<AggregatedReportView data={aggregatedData} />
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-600">Нет данных для агрегированного отчета</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'sections' && (
|
||||
<>
|
||||
{selectedDistrict ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDistrict(null);
|
||||
setDistrictAggregatedData(null);
|
||||
}}
|
||||
className="mb-4 flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад к списку участков
|
||||
</button>
|
||||
{loadingAggregated ? (
|
||||
<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>
|
||||
) : districtAggregatedData ? (
|
||||
<AggregatedReportView data={districtAggregatedData} />
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-600">Нет данных для отчета по участку</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(sections).map(([sectionName, sectionBuildings]) => {
|
||||
const districtId = sectionBuildings[0]?.district_id;
|
||||
const totalIncome = sectionBuildings.reduce((sum, b) => sum + b.total_income, 0);
|
||||
const totalExpenses = sectionBuildings.reduce((sum, b) => sum + b.total_expenses, 0);
|
||||
const totalBalance = sectionBuildings.reduce((sum, b) => sum + b.balance, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sectionName}
|
||||
onClick={() => districtId && setSelectedDistrict(districtId)}
|
||||
className="bg-white rounded-2xl border-2 border-slate-200 hover:border-primary-400 p-6 cursor-pointer transition-all hover:shadow-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MapPin className="w-5 h-5 text-primary-600" />
|
||||
<h3 className="text-lg font-bold text-slate-800">{sectionName}</h3>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">Домов:</span>
|
||||
<span className="font-bold text-slate-800">{sectionBuildings.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
Доходы:
|
||||
</span>
|
||||
<span className="font-bold text-emerald-600">{formatCurrency(totalIncome)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||
Расходы:
|
||||
</span>
|
||||
<span className="font-bold text-red-600">{formatCurrency(totalExpenses)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-200">
|
||||
<span className="text-slate-700 font-medium flex items-center gap-1">
|
||||
<DollarSign className="w-4 h-4 text-primary-600" />
|
||||
Баланс:
|
||||
</span>
|
||||
<span className={`font-bold text-lg ${
|
||||
totalBalance >= 0 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(totalBalance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-primary-600 font-medium text-center">
|
||||
Нажмите для просмотра отчета
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'specific' && (
|
||||
<>
|
||||
{selectedBuilding && (buildingAggregatedData || selectedBuildingReport) ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBuilding(null);
|
||||
setBuildingAggregatedData(null);
|
||||
setSelectedBuildingReport(null);
|
||||
}}
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад к списку домов
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBuildingReportViewMode('detailed')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
buildingReportViewMode === 'detailed'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Детальный отчет
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBuildingReportViewMode('aggregated')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
buildingReportViewMode === 'aggregated'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Агрегированный отчет
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingAggregated ? (
|
||||
<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>
|
||||
) : buildingReportViewMode === 'aggregated' && buildingAggregatedData ? (
|
||||
<AggregatedReportView data={buildingAggregatedData} />
|
||||
) : buildingReportViewMode === 'detailed' && selectedBuildingReport?.content ? (
|
||||
<ResidentReportView
|
||||
content={selectedBuildingReport.content}
|
||||
buildingAddress={selectedBuildingReport.address}
|
||||
period={selectedBuildingReport.periodStart && selectedBuildingReport.periodEnd
|
||||
? `${new Date(selectedBuildingReport.periodStart).toLocaleDateString('ru-RU')} - ${new Date(selectedBuildingReport.periodEnd).toLocaleDateString('ru-RU')}`
|
||||
: selectedBuildingReport.month}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-600">Отчет по дому не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Выберите дом</h3>
|
||||
<div className={displayMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{buildings.map(building =>
|
||||
renderBuildingCard(building, () => setSelectedBuilding(building.id))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{buildings.length === 0 && (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12">
|
||||
<div className="text-center mb-6">
|
||||
<Building2 className="w-12 h-12 text-amber-400 mx-auto mb-4" />
|
||||
<p className="text-slate-600 font-medium mb-2">Нет данных по домам для этого отчета</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Возможные причины:
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-lg p-4 space-y-2 text-sm text-slate-700">
|
||||
<p>• Адреса домов в ведомости не совпадают с адресами в базе данных</p>
|
||||
<p>• Дома еще не добавлены в систему</p>
|
||||
<p>• Ошибка при парсинге ведомости</p>
|
||||
</div>
|
||||
{report.errorLog && report.errorLog.notFoundAddresses && (
|
||||
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-amber-800 mb-2">
|
||||
⚠ Не найдено домов: {report.errorLog.notFoundAddresses.length}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mb-3">
|
||||
{report.errorLog.message || 'Данные по этим адресам не были загружены. Создайте дома вручную в системе, затем загрузите отчет повторно.'}
|
||||
</p>
|
||||
<div className="text-xs text-amber-700 space-y-1 max-h-40 overflow-y-auto">
|
||||
{report.errorLog.notFoundAddresses.slice(0, 10).map((addr: string, idx: number) => (
|
||||
<div key={idx}>• {addr}</div>
|
||||
))}
|
||||
{report.errorLog.notFoundAddresses.length > 10 && (
|
||||
<div className="text-amber-600 font-medium">
|
||||
... и еще {report.errorLog.notFoundAddresses.length - 10} адресов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user