860 lines
39 KiB
TypeScript
Executable File
860 lines
39 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|
||
|