Files
mkd/components/pr/BuildingReportPage.tsx
2026-02-04 00:17:04 +05:00

1453 lines
69 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 { ChevronLeft, ChevronRight, Calendar, Download, Share2, Building2, CheckCircle2, TrendingUp, DollarSign, Scale, PartyPopper, Smile, Camera, ClipboardList, ArrowLeft, RefreshCw, Send, Link2, X, Lock, Receipt, Wrench, Zap, Users, Key, Eye } from 'lucide-react';
import { apiClient } from '../../services/apiClient';
// Базовый URL для загрузок (фото до/после), как в таблице «Фото отчёт» PR
const UPLOADS_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api').replace(/\/api\/?$/, '') || 'http://localhost:4000';
const toPhotoUrl = (path: string | null | undefined) => {
if (!path) return undefined;
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const base = UPLOADS_BASE.endsWith('/') ? UPLOADS_BASE.slice(0, -1) : UPLOADS_BASE;
const p = path.startsWith('/') ? path : `/${path}`;
return base + p;
};
interface BuildingReportPageProps {
buildingId?: string;
buildingAddress?: string;
month?: string;
onBack?: () => void;
mode?: 'portal' | 'published'; // Режим: портал (редактирование) или опубликованная версия
reportId?: string | number;
accessKey?: string; // Ключ доступа для опубликованной версии
}
export const BuildingReportPage: React.FC<BuildingReportPageProps> = (props) => {
const {
buildingId: buildingIdProp,
buildingAddress = 'Кавказская, 12',
month = 'Январь 2025',
onBack,
mode = 'portal',
reportId,
accessKey
} = props || {};
const [currentMonth, setCurrentMonth] = useState(month);
const [showArchive, setShowArchive] = useState(false);
const [showArchiveCalendar, setShowArchiveCalendar] = useState(false);
const [selectedYear, setSelectedYear] = useState<number | null>(null);
const [companySettings, setCompanySettings] = useState<any | null>(null);
const [isAuthorized, setIsAuthorized] = useState(false);
const [accessKeyInput, setAccessKeyInput] = useState('');
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
// Данные отчета
const [reportData, setReportData] = useState<any>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
// Для published режима: загружаем отчет по ID, чтобы получить buildingId
const [loadedBuildingId, setLoadedBuildingId] = useState<string | undefined>(buildingIdProp);
const [loadedBuildingAddress, setLoadedBuildingAddress] = useState<string>(buildingAddress);
const [loadedMonth, setLoadedMonth] = useState<string>(month);
const effectiveReportId = reportId ?? 'demo';
// Определяем buildingId: используем пропс, загруженный из отчета, или reportId (если это buildingId)
const buildingId = buildingIdProp ?? loadedBuildingId ?? (reportId && reportId !== 'demo' && !/^\d+$/.test(String(reportId)) ? String(reportId) : undefined);
// Уникальный ключ для каждого отчёта (демо-реализация)
const expectedAccessKey = `mkd-${String(effectiveReportId)}-key`;
// Проверка ключа доступа для опубликованной версии
useEffect(() => {
if (mode === 'published') {
// Получаем ключ из URL параметров или пропсов
const urlParams = new URLSearchParams(window.location.search);
const keyFromUrl = urlParams.get('key') || accessKey;
if (keyFromUrl === expectedAccessKey) {
setIsAuthorized(true);
} else if (!keyFromUrl) {
// Если ключа нет, показываем форму ввода
setIsAuthorized(false);
} else {
// Неверный ключ
alert('Неверный ключ доступа');
setIsAuthorized(false);
}
} else {
setIsAuthorized(true);
}
}, [mode, accessKey, expectedAccessKey]);
const months = [
'Январь', 'Февраль', 'Март', 'Апрель',
'Май', 'Июнь', 'Июль', 'Август',
'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
// Надёжный разбор месяца: поддерживаем "Январь 2025", "января 2025", "January 2025" и т.д.
const currentYear = parseInt(currentMonth.replace(/\D/g, ' ').trim().split(/\s+/).pop() || String(new Date().getFullYear()), 10) || new Date().getFullYear();
const currentMonthName = currentMonth.split(/\s+/)[0] || '';
let currentMonthIndex = months.findIndex(m => m.toLowerCase() === currentMonthName.toLowerCase());
if (currentMonthIndex === -1) {
const monthLower = currentMonthName.toLowerCase();
const genitiveMap: Record<string, number> = { январ: 0, феврал: 1, март: 2, апрел: 3, май: 4, июн: 5, июл: 6, август: 7, сентябр: 8, октябр: 9, ноябр: 10, декабр: 11 };
for (const [key, idx] of Object.entries(genitiveMap)) {
if (monthLower.startsWith(key) || monthLower.includes(key)) { currentMonthIndex = idx; break; }
}
}
if (currentMonthIndex === -1) currentMonthIndex = new Date().getMonth();
// Для published режима: загружаем отчет по ID, чтобы получить buildingId и адрес
useEffect(() => {
if (mode === 'published' && effectiveReportId && effectiveReportId !== 'demo') {
const loadReportInfo = async () => {
try {
// Проверяем, является ли reportId числом (ID отчета) или строкой (buildingId)
const isNumericId = /^\d+$/.test(String(effectiveReportId));
if (isNumericId) {
// reportId - это ID отчета, загружаем отчет по ID
const report = await apiClient.get<any>(`/pr/reports/${effectiveReportId}`);
if (report?.buildingId || report?.building_id) {
setLoadedBuildingId(report.buildingId || report.building_id);
}
// Извлекаем адрес из разных возможных мест (приоритет: content.building.address)
const address = (report?.content?.building?.address) ||
report?.address ||
(report?.building_data?.passport?.address) ||
(report?.building_data?.passport?.general?.address);
if (address) {
console.log('[BuildingReportPage] Установлен адрес из отчета:', address);
setLoadedBuildingAddress(address);
} else {
console.warn('[BuildingReportPage] Адрес не найден в отчете');
}
if (report?.month) {
setLoadedMonth(report.month);
setCurrentMonth(report.month);
}
} else {
// reportId - это buildingId, используем его напрямую
setLoadedBuildingId(String(effectiveReportId));
}
} catch (err) {
console.error('Error loading report info:', err);
}
};
loadReportInfo();
}
}, [mode, effectiveReportId]);
// Загрузка данных отчета
useEffect(() => {
if (effectiveReportId && effectiveReportId !== 'demo') {
loadReportData();
} else if (effectiveReportId === 'demo') {
// Для демо используем мок данные
setReportData({
building: { address: buildingAddress, imageUrl: '' },
company: null,
stats: {
appsQuality: 98,
appsTotal: 156,
appsCompleted: 153,
tasksTotal: 25,
tasksCompleted: 24,
fundsCollected: 2400000,
fundsSpent: 1890000,
fundsBalance: 560000,
debtCasesWon: 12,
debtCollected: 450000
},
expenses: {
total: 1890000,
byCategory: {
utilities: 850000,
maintenance: 720000,
management: 220000,
other: 100000
}
},
events: [],
nps: { score: 72, totalResponses: 0, promoters: 0, passives: 0, detractors: 0 },
workPhotos: [],
planItems: []
});
}
}, [effectiveReportId, currentMonth, currentYear, buildingId, loadedBuildingAddress]);
// Загрузка настроек управляющей компании (для блока информации о компании в отчете)
useEffect(() => {
const loadCompanySettings = async () => {
try {
const data = await apiClient.get('/settings/company');
setCompanySettings(data);
} catch (err) {
console.error('Error loading company settings:', err);
}
};
loadCompanySettings();
}, []);
const loadReportData = async (forceRefresh = false) => {
try {
setIsLoadingData(true);
const monthNum = currentMonthIndex + 1;
// Добавляем timestamp для предотвращения кэширования; при нажатии «Обновить данные» — force_refresh, чтобы пересчитать из таблиц
const timestamp = new Date().getTime();
const forceParam = forceRefresh ? '&force_refresh=1' : '';
// Используем buildingId из пропсов, загруженный из отчета, или из reportData
const effectiveBuildingId = buildingId ?? loadedBuildingId ?? reportData?.building?.id ?? (effectiveReportId !== 'demo' && !/^\d+$/.test(String(effectiveReportId)) ? effectiveReportId : undefined);
let data;
// Проверяем, является ли reportId числом (ID отчета) или строкой (buildingId)
const isNumericReportId = /^\d+$/.test(String(effectiveReportId));
// Для published режима: используем сохраненный контент отчета
if (mode === 'published' && isNumericReportId && !forceRefresh) {
try {
// Загружаем сохраненный отчет с content
const savedReport = await apiClient.get<any>(`/pr/reports/${effectiveReportId}`);
// Проверяем, что content существует и содержит данные
const hasValidContent = savedReport?.content &&
typeof savedReport.content === 'object' &&
Object.keys(savedReport.content).length > 0 &&
(savedReport.content.stats || savedReport.content.expenses || savedReport.content.nps);
console.log('[BuildingReportPage] Загружен сохраненный отчет:', {
hasContent: !!savedReport?.content,
contentType: typeof savedReport?.content,
contentKeys: savedReport?.content ? Object.keys(savedReport.content) : [],
hasStats: !!savedReport?.content?.stats,
hasExpenses: !!savedReport?.content?.expenses,
buildingAddress: savedReport?.content?.building?.address,
hasValidContent: hasValidContent
});
if (hasValidContent) {
console.log('[BuildingReportPage] Используем сохраненный контент отчета для published режима');
// Преобразуем сохраненный контент в формат данных отчета
const content = savedReport.content as any;
// Обновляем адрес из сохраненного контента ПЕРЕД загрузкой данных
if (content.building?.address) {
console.log('[BuildingReportPage] Обновляем адрес из content:', content.building.address);
setLoadedBuildingAddress(content.building.address);
}
// Загружаем базовую информацию о доме и компании (для building и company объектов)
const reportDataResponse = await apiClient.get<any>(`/pr/reports/${effectiveReportId}/data?month=${monthNum}&year=${currentYear}&building_id=${effectiveBuildingId || savedReport.buildingId || savedReport.building_id || ''}&_t=${timestamp}`).catch(() => ({}));
// Используем данные из сохраненного content (структура: content.stats, content.expenses, content.nps и т.д.)
const baseData = reportDataResponse || {};
data = {
...baseData,
// Используем данные из сохраненного content (приоритет content над baseData)
building: content.building || baseData.building || {},
company: content.company || baseData.company || null,
stats: content.stats || baseData.stats || {},
expenses: content.expenses || baseData.expenses || {},
nps: content.nps || baseData.nps || {},
events: content.events || baseData.events || [],
workPhotos: content.workPhotos || baseData.workPhotos || [],
planItems: content.planItems || baseData.planItems || []
};
console.log('[BuildingReportPage] Данные из content:', {
hasStats: !!data.stats,
statsKeys: data.stats ? Object.keys(data.stats) : [],
appsQuality: data.stats?.appsQuality,
appsTotal: data.stats?.appsTotal,
fundsCollected: data.stats?.fundsCollected,
buildingAddress: data.building?.address
});
setReportData(data);
return;
} else {
console.warn('[BuildingReportPage] Сохраненный отчет не содержит content или content пустой, загружаем данные из /data');
// Если content пустой, все равно загружаем данные из /data endpoint
// Но сначала обновляем адрес из savedReport, если он есть
if (savedReport?.address) {
setLoadedBuildingAddress(savedReport.address);
}
}
} catch (e) {
console.error('[BuildingReportPage] Ошибка загрузки сохраненного отчета, загружаем данные заново:', e);
}
}
// Если reportId это buildingId (строка) или у нас есть effectiveBuildingId, используем buildingId напрямую
if (effectiveBuildingId && (!isNumericReportId || effectiveReportId === 'demo')) {
// Загружаем данные напрямую по buildingId и месяцу
data = await apiClient.get(`/pr/reports/${effectiveBuildingId}/data?month=${monthNum}&year=${currentYear}&building_id=${effectiveBuildingId}&_t=${timestamp}${forceParam}`);
} else {
// reportId - это ID отчета (число), загружаем данные по ID отчета
// Для published режима уже попытались загрузить content выше, теперь загружаем актуальные данные
data = await apiClient.get(`/pr/reports/${effectiveReportId}/data?month=${monthNum}&year=${currentYear}&building_id=${effectiveBuildingId || ''}&_t=${timestamp}${forceParam}`);
// Если это published режим и данные загружены, обновляем адрес из данных
if (mode === 'published' && data?.building?.address) {
setLoadedBuildingAddress(data.building.address);
}
}
// Обновляем адрес из загруженных данных, если он есть
if (data?.building?.address) {
console.log('[BuildingReportPage] Обновляем адрес из загруженных данных:', data.building.address);
setLoadedBuildingAddress(data.building.address);
}
// Если данные приходят в формате с content (из сохраненного отчета), извлекаем их
if (data?.content) {
console.log('[BuildingReportPage] Данные пришли в формате content, извлекаем...');
// Объединяем данные из content с основными данными
const content = data.content;
// NPS из content
if (content.nps) {
data.nps = {
score: content.nps.score || 0,
totalResponses: content.nps.totalResponses || 0,
promoters: content.nps.promoters || 0,
passives: content.nps.passives || 0,
detractors: content.nps.detractors || 0,
avgScore: content.nps.avgScore || 0
};
}
// Stats из content.applications
if (content.applications) {
if (!data.stats) data.stats = {};
data.stats.appsQuality = content.applications.quality || data.stats.appsQuality || 0;
data.stats.appsTotal = content.applications.total || data.stats.appsTotal || 0;
data.stats.appsCompleted = content.applications.completed || data.stats.appsCompleted || 0;
}
// Finances из content.finances
if (content.finances) {
if (!data.stats) data.stats = {};
data.stats.fundsCollected = content.finances.collected || data.stats.fundsCollected || 0;
data.stats.fundsSpent = content.finances.expenses || data.stats.fundsSpent || 0;
data.stats.fundsBalance = content.finances.balance || data.stats.fundsBalance || 0;
if (!data.expenses) data.expenses = {};
data.expenses.total = content.finances.expenses || data.expenses.total || 0;
}
}
// Логируем данные для отладки
console.log('[BuildingReportPage] Загруженные данные:', {
hasNps: !!data?.nps,
npsScore: data?.nps?.score,
npsTotalResponses: data?.nps?.totalResponses,
hasStats: !!data?.stats,
statsKeys: data?.stats ? Object.keys(data.stats) : [],
appsQuality: data?.stats?.appsQuality,
appsTotal: data?.stats?.appsTotal,
fundsCollected: data?.stats?.fundsCollected,
hasContent: !!data?.content,
buildingAddress: data?.building?.address,
period: `${currentMonth} ${currentYear}`
});
setReportData(data);
} catch (err) {
console.error('Error loading report data:', err);
// Используем мок данные при ошибке
setReportData(null);
} finally {
setIsLoadingData(false);
}
};
// Показываем индикатор загрузки
if (isLoadingData && !reportData) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка данных отчета...</p>
</div>
</div>
);
}
const handlePrevMonth = () => {
let newIndex = currentMonthIndex - 1;
let newYear = currentYear;
if (newIndex < 0) {
newIndex = 11;
newYear--;
}
setCurrentMonth(`${months[newIndex]} ${newYear}`);
};
const handleNextMonth = () => {
let newIndex = currentMonthIndex + 1;
let newYear = currentYear;
if (newIndex > 11) {
newIndex = 0;
newYear++;
}
setCurrentMonth(`${months[newIndex]} ${newYear}`);
};
const handlePublish = async () => {
if (!reportId || reportId === 'demo') {
alert('Это демо-отчет. Создайте реальный отчет для публикации.');
return;
}
try {
// При публикации отчет обновляется актуальными данными
await apiClient.post(`/pr/reports/${reportId}/publish`);
alert('Отчет обновлен актуальными данными и опубликован!');
// Перезагружаем данные отчета
await loadReportData();
} catch (err: any) {
console.error('Error publishing report:', err);
alert(`Ошибка публикации отчета: ${err.message || 'Неизвестная ошибка'}`);
}
};
const handleGetLink = () => {
if (!reportId) return;
setShowAccessKeyModal(true);
};
// Генерируем список годов для архива (текущий год и 5 лет назад)
const currentYearNum = new Date().getFullYear();
const archiveYears = Array.from({ length: 6 }, (_, i) => currentYearNum - i);
// Для опубликованной версии - проверка доступа
if (mode === 'published' && !isAuthorized) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 flex items-center justify-center">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-slate-200">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-100 rounded-full mb-4">
<Lock className="w-8 h-8 text-indigo-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-2">Доступ к отчету</h2>
<p className="text-sm text-slate-600">Введите ключ доступа для просмотра отчета</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-2 uppercase tracking-wider">
Ключ доступа
</label>
<input
type="password"
value={accessKeyInput}
onChange={(e) => setAccessKeyInput(e.target.value)}
placeholder="Введите ключ доступа"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
onKeyPress={(e) => {
if (e.key === 'Enter') {
if (accessKeyInput === expectedAccessKey) {
setIsAuthorized(true);
// Обновляем URL с ключом
const url = new URL(window.location.href);
url.searchParams.set('key', accessKeyInput);
window.history.pushState({}, '', url.toString());
} else {
alert('Неверный ключ доступа');
setAccessKeyInput('');
}
}
}}
/>
</div>
<button
onClick={() => {
if (accessKeyInput === expectedAccessKey) {
setIsAuthorized(true);
const url = new URL(window.location.href);
url.searchParams.set('key', accessKeyInput);
window.history.pushState({}, '', url.toString());
} else {
alert('Неверный ключ доступа');
setAccessKeyInput('');
}
}}
className="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Войти
</button>
</div>
<p className="text-xs text-slate-400 text-center mt-6">
Ключ доступа выдается управляющей компанией
</p>
</div>
</div>
);
}
// Для опубликованной версии - убираем верхнее меню и все элементы управления
if (mode === 'published') {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Hero Section - Building Photo */}
<div className="relative mb-8 rounded-3xl overflow-hidden shadow-2xl">
<div className="relative h-96 bg-gradient-to-br from-primary-600 via-primary-500 to-indigo-600">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjYwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiM0ZjQ2ZjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM2MzY2ZjEiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iODAwIiBoZWlnaHQ9IjYwMCIgZmlsbD0idXJsKCNhKSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSI0OCIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBvcGFjaXR5PSIwLjMiPkJ1aWxkaW5nIEltYWdlPC90ZXh0Pjwvc3ZnPg==')] bg-cover bg-center opacity-20"></div>
<div className="absolute inset-0 flex items-end">
<div className="w-full bg-gradient-to-t from-black/80 via-black/40 to-transparent p-8 md:p-12">
<h1 className="text-4xl md:text-6xl font-black text-white mb-2 drop-shadow-2xl">
{loadedBuildingAddress || buildingAddress}
</h1>
<p className="text-lg md:text-xl text-white/90 font-bold">
Ежемесячный отчет для собственников
</p>
</div>
</div>
</div>
</div>
{/* Month Navigation - только для опубликованной версии */}
<div className="mb-8 flex flex-col sm:flex-row items-center justify-between gap-4 bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<div className="flex items-center gap-4">
<button
onClick={handlePrevMonth}
className="p-3 rounded-xl bg-primary-50 text-primary-600 hover:bg-primary-100 active:scale-95 transition-all"
>
<ChevronLeft className="w-6 h-6" />
</button>
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-primary-600" />
<h2 className="text-2xl font-black text-slate-800">{currentMonth}</h2>
</div>
<button
onClick={handleNextMonth}
className="p-3 rounded-xl bg-primary-50 text-primary-600 hover:bg-primary-100 active:scale-95 transition-all"
>
<ChevronRight className="w-6 h-6" />
</button>
</div>
<div className="relative">
<button
onClick={() => setShowArchiveCalendar(!showArchiveCalendar)}
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
Архив
</button>
{/* Archive Calendar Dropdown */}
{showArchiveCalendar && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => {
setShowArchiveCalendar(false);
setSelectedYear(null);
}}
/>
<div className="absolute right-0 top-full mt-2 bg-white rounded-2xl shadow-2xl border border-slate-200 p-6 z-50 min-w-[300px]">
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">Выберите период</h3>
<button
onClick={() => {
setShowArchiveCalendar(false);
setSelectedYear(null);
}}
className="p-1 hover:bg-slate-100 rounded-lg"
>
<X className="w-4 h-4 text-slate-500" />
</button>
</div>
{selectedYear === null ? (
<div className="space-y-2">
<p className="text-xs font-bold text-slate-500 uppercase mb-3">Выберите год</p>
{archiveYears.map(year => (
<button
key={year}
onClick={() => setSelectedYear(year)}
className="w-full px-4 py-3 bg-slate-50 hover:bg-primary-50 hover:text-primary-600 rounded-xl text-left font-bold transition-colors"
>
{year}
</button>
))}
</div>
) : (
<div>
<button
onClick={() => setSelectedYear(null)}
className="mb-4 text-sm text-primary-600 hover:text-primary-700 font-bold flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Назад к годам
</button>
<p className="text-xs font-bold text-slate-500 uppercase mb-3">Выберите месяц {selectedYear}</p>
<div className="grid grid-cols-3 gap-2">
{months.map((monthName, index) => (
<button
key={index}
onClick={() => {
setCurrentMonth(`${monthName} ${selectedYear}`);
setShowArchiveCalendar(false);
setSelectedYear(null);
}}
className="px-3 py-2 bg-slate-50 hover:bg-primary-50 hover:text-primary-600 rounded-lg text-sm font-bold transition-colors"
>
{monthName}
</button>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Rest of content - same as portal version */}
{renderContent()}
</div>
);
}
// Версия для портала (редактирование)
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Header with back button and action buttons */}
{onBack && (
<div className="sticky top-[64px] z-50 bg-white/80 backdrop-blur-md border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors group"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span className="text-sm font-bold">Вернуться к списку отчетов</span>
</button>
<div className="flex items-center gap-3">
<button
onClick={() => loadReportData(true)}
disabled={isLoadingData}
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold flex items-center gap-2 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${isLoadingData ? 'animate-spin' : ''}`} />
{isLoadingData ? 'Обновление…' : 'Обновить данные'}
</button>
<button
onClick={handlePublish}
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 transition-colors"
>
<Send className="w-4 h-4" />
Опубликовать
</button>
<button
onClick={handleGetLink}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 transition-colors"
>
<Key className="w-4 h-4" />
Получить ключ доступа
</button>
</div>
</div>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && (
<AccessKeyModal
onClose={() => setShowAccessKeyModal(false)}
reportId={reportId || 'demo'}
/>
)}
{/* Hero Section - Building Photo */}
<div className="relative mb-8 rounded-3xl overflow-hidden shadow-2xl">
<div className="relative h-96 bg-gradient-to-br from-primary-600 via-primary-500 to-indigo-600">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjYwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiM0ZjQ2ZjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM2MzY2ZjEiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iODAwIiBoZWlnaHQ9IjYwMCIgZmlsbD0idXJsKCNhKSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSI0OCIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBvcGFjaXR5PSIwLjMiPkJ1aWxkaW5nIEltYWdlPC90ZXh0Pjwvc3ZnPg==')] bg-cover bg-center opacity-20"></div>
<div className="absolute inset-0 flex items-end">
<div className="w-full bg-gradient-to-t from-black/80 via-black/40 to-transparent p-8 md:p-12">
<h1 className="text-4xl md:text-6xl font-black text-white mb-2 drop-shadow-2xl">
{loadedBuildingAddress || buildingAddress}
</h1>
<p className="text-lg md:text-xl text-white/90 font-bold">
Ежемесячный отчет для собственников
</p>
</div>
</div>
</div>
</div>
{/* Month Navigation - для портала */}
<div className="mb-8 flex flex-col sm:flex-row items-center justify-between gap-4 bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<div className="flex items-center gap-4">
<button
onClick={handlePrevMonth}
className="p-3 rounded-xl bg-primary-50 text-primary-600 hover:bg-primary-100 active:scale-95 transition-all"
>
<ChevronLeft className="w-6 h-6" />
</button>
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-primary-600" />
<h2 className="text-2xl font-black text-slate-800">{currentMonth}</h2>
</div>
<button
onClick={handleNextMonth}
className="p-3 rounded-xl bg-primary-50 text-primary-600 hover:bg-primary-100 active:scale-95 transition-all"
>
<ChevronRight className="w-6 h-6" />
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowArchive(!showArchive)}
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
Архив
</button>
</div>
</div>
{/* Render main content */}
{renderContent()}
</div>
</div>
);
// Функция для рендеринга основного контента (используется в обеих версиях)
function renderContent() {
const company = companySettings || reportData?.company;
return (
<>
{/* Company Info & Logo Section */}
{company && (
<div className="mb-8 bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-4">
{company.logoUrl ? (
<img src={company.logoUrl} alt={company.name} className="w-16 h-16 rounded-2xl object-cover shadow-lg" />
) : (
<div className="w-16 h-16 bg-gradient-to-br from-primary-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg">
<Building2 className="w-8 h-8 text-white" />
</div>
)}
<div>
<h3 className="text-xl font-black text-slate-800">{company.name}</h3>
{company.address && (
<p className="text-sm text-slate-600 mt-1">{company.address}</p>
)}
<p className="text-xs text-slate-500 mt-1">
{company.phone && `Телефон: ${company.phone}`}
{company.phone && company.email && ' • '}
{company.email && `Email: ${company.email}`}
</p>
</div>
</div>
{company.licenseNumber && (
<div className="text-right">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">{company.licenseNumber}</p>
{company.licenseValidUntil && (
<p className="text-xs text-slate-400 mt-1">Действительна до {new Date(company.licenseValidUntil).toLocaleDateString('ru-RU')}</p>
)}
</div>
)}
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* 1. Качество отработок по заявкам */}
<StatCard
icon={CheckCircle2}
title="Качество отработок"
value={reportData?.stats?.appsQuality ? `${reportData.stats.appsQuality}%` : '0%'}
subtitle="Заявок выполнено"
trend={reportData?.stats?.appsTotal ? `+${Math.round((reportData.stats.appsCompleted / reportData.stats.appsTotal) * 100)}%` : '0%'}
color="emerald"
details={reportData?.stats ? `Из ${reportData.stats.appsTotal} заявок выполнено ${reportData.stats.appsCompleted} в срок` : 'Нет данных'}
/>
{/* 2. Количество задач из календарного плана */}
<StatCard
icon={ClipboardList}
title="Задачи выполнены"
value={reportData?.stats?.tasksCompleted?.toString() || '0'}
subtitle={`из ${reportData?.stats?.tasksTotal || 0} запланированных`}
trend={reportData?.stats?.tasksTotal ? `${Math.round((reportData.stats.tasksCompleted / reportData.stats.tasksTotal) * 100)}%` : '0%'}
color="blue"
details={reportData?.stats?.tasksTotal ? 'Календарный план выполнен' : 'Нет данных'}
/>
{/* 3. Количество собранных денег */}
<StatCard
icon={DollarSign}
title="Собрано средств"
value={reportData?.stats?.fundsCollected ? `${(reportData.stats.fundsCollected / 1000000).toFixed(1)}M ₽` : '0 ₽'}
subtitle="за отчетный период"
trend={reportData?.stats?.fundsCollected ? '+5.2%' : '0%'}
color="amber"
details="Платежная дисциплина: 94%"
/>
{/* 4. Количество долгов и выигранных дел */}
<StatCard
icon={Scale}
title="Работа с долгами"
value={reportData?.stats?.debtCasesWon?.toString() || '0'}
subtitle="дел выиграно"
trend={reportData?.stats?.debtCollected ? `Взыскано ${(reportData.stats.debtCollected / 1000).toFixed(0)}K ₽` : '0 ₽'}
color="purple"
details={reportData?.stats?.debtCollected ? `Взыскано ${reportData.stats.debtCollected.toLocaleString('ru-RU')}` : 'Нет данных'}
/>
</div>
{/* Расходы по дому */}
<div className="mb-8">
<ContentCard
icon={Receipt}
title="Расходы по дому за отчетный период"
color="teal"
fullWidth
>
<div className="space-y-6">
{/* Общая информация */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-6 bg-gradient-to-br from-teal-50 to-cyan-50 rounded-2xl border border-teal-100">
<div className="text-center">
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Собрано средств</p>
<p className="text-2xl font-black text-slate-800">
{reportData?.stats?.fundsCollected ? reportData.stats.fundsCollected.toLocaleString('ru-RU') : '0'}
</p>
</div>
<div className="text-center border-x border-teal-200">
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Потрачено</p>
<p className="text-2xl font-black text-slate-800">
{reportData?.expenses?.total ? reportData.expenses.total.toLocaleString('ru-RU') : '0'}
</p>
</div>
<div className="text-center">
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Остаток</p>
<p className="text-2xl font-black text-emerald-600">
{reportData?.stats?.fundsBalance ? reportData.stats.fundsBalance.toLocaleString('ru-RU') : '0'}
</p>
</div>
</div>
{/* Расходы по категориям */}
<div className="space-y-4">
<h4 className="text-lg font-black text-slate-800 mb-4">Распределение расходов</h4>
{reportData?.expenses?.byCategory && (
<>
{reportData.expenses.byCategory.utilities > 0 && (
<ExpenseCategory
icon={Zap}
category="Коммунальные услуги"
amount={reportData.expenses.byCategory.utilities}
total={reportData.expenses.total || 1}
items={Object.entries(reportData.expenses.details || {})
.filter(([name]) => ['электроэнергия', 'электричество', 'водоснабжение', 'водоотведение', 'отопление', 'газ'].some(kw => name.toLowerCase().includes(kw)))
.map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))}
color="blue"
/>
)}
{reportData.expenses.byCategory.maintenance > 0 && (
<ExpenseCategory
icon={Wrench}
category="Содержание и ремонт"
amount={reportData.expenses.byCategory.maintenance}
total={reportData.expenses.total || 1}
items={Object.entries(reportData.expenses.details || {})
.filter(([name]) => ['ремонт', 'содержание', 'лифт', 'уборка', 'мусор', 'материалы'].some(kw => name.toLowerCase().includes(kw)))
.map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))}
color="amber"
/>
)}
{reportData.expenses.byCategory.management > 0 && (
<ExpenseCategory
icon={Users}
category="Управление и обслуживание"
amount={reportData.expenses.byCategory.management}
total={reportData.expenses.total || 1}
items={Object.entries(reportData.expenses.details || {})
.filter(([name]) => ['зарплата', 'персонал', 'административные', 'страхование', 'управление'].some(kw => name.toLowerCase().includes(kw)))
.map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))}
color="purple"
/>
)}
{reportData.expenses.byCategory.other > 0 && (
<ExpenseCategory
icon={Receipt}
category="Прочие расходы"
amount={reportData.expenses.byCategory.other}
total={reportData.expenses.total || 1}
items={Object.entries(reportData.expenses.details || {})
.filter(([name]) => !['электроэнергия', 'электричество', 'водоснабжение', 'водоотведение', 'отопление', 'газ', 'ремонт', 'содержание', 'лифт', 'уборка', 'мусор', 'материалы', 'зарплата', 'персонал', 'административные', 'страхование', 'управление'].some(kw => name.toLowerCase().includes(kw)))
.map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))}
color="slate"
/>
)}
</>
)}
{(!reportData?.expenses?.byCategory || reportData.expenses.total === 0) && (
<div className="text-center py-8 text-slate-500">
<p>Нет данных о расходах за этот период</p>
</div>
)}
</div>
{/* Примечание */}
<div className="mt-6 p-4 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-xs text-slate-600 leading-relaxed">
<strong className="font-black text-slate-800">Важно:</strong> Все расходы подтверждены документами и соответствуют
тарифам, утвержденным на общем собрании собственников. Детальная информация по каждой статье расходов
доступна в управляющей компании по предварительной записи.
</p>
</div>
</div>
</ContentCard>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* 5. Мероприятия */}
<ContentCard
icon={PartyPopper}
title="Мероприятия"
color="pink"
>
<div className="space-y-4">
{reportData?.events && reportData.events.length > 0 ? (
reportData.events.map((event: any, idx: number) => (
<EventItem
key={event.id || idx}
date={event.date ? new Date(event.date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) : ''}
title={event.title}
description={event.description || (event.attendeesCount ? `Присутствовало ${event.attendeesCount} человек` : '')}
/>
))
) : (
<p className="text-center text-slate-500 py-4">Нет мероприятий за этот период</p>
)}
</div>
</ContentCard>
{/* 6. NPS */}
<ContentCard
icon={Smile}
title="Индекс удовлетворенности (NPS)"
color="indigo"
>
<div className="space-y-6">
<div className="text-center">
<div className={`inline-flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white mb-4 shadow-xl ${reportData?.nps?.score === undefined ? 'opacity-50' : ''}`}>
<div className="text-center">
<div className="text-5xl font-black">{reportData?.nps?.score ?? 0}</div>
<div className="text-sm font-bold opacity-90">NPS</div>
</div>
</div>
<p className="text-lg font-bold text-slate-800">
{reportData?.nps?.score !== undefined
? reportData.nps.score >= 50 ? 'Отличный показатель'
: reportData.nps.score >= 0 ? 'Хороший показатель'
: 'Требует внимания'
: 'Нет данных'}
</p>
{reportData?.nps?.totalResponses ? (
<p className="text-sm text-slate-600 mt-1">Всего ответов: {reportData.nps.totalResponses}</p>
) : (
<p className="text-sm text-slate-600 mt-1">Нет ответов за этот период</p>
)}
</div>
{reportData?.nps && reportData.nps.totalResponses > 0 && (
<div className="space-y-3 pt-4 border-t border-slate-200">
<NPSBar
label="Промоутеры"
value={Math.round((reportData.nps.promoters / reportData.nps.totalResponses) * 100)}
color="emerald"
/>
<NPSBar
label="Нейтральные"
value={Math.round((reportData.nps.passives / reportData.nps.totalResponses) * 100)}
color="amber"
/>
<NPSBar
label="Критики"
value={Math.round((reportData.nps.detractors / reportData.nps.totalResponses) * 100)}
color="red"
/>
</div>
)}
</div>
</ContentCard>
</div>
{/* 7. Фото отчет задач до/после */}
<div className="mb-8">
<ContentCard
icon={Camera}
title="Фото отчет выполненных работ"
color="cyan"
fullWidth
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{reportData?.workPhotos && reportData.workPhotos.length > 0 ? (
reportData.workPhotos.map((photo: any) => (
<PhotoComparison
key={photo.id}
title={photo.workName}
date={photo.workDate ? new Date(photo.workDate).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) : ''}
beforeColor="from-slate-400 to-slate-600"
afterColor="from-emerald-400 to-emerald-600"
beforeUrl={toPhotoUrl(photo.photoBeforeUrl ?? photo.photo_before_url)}
afterUrl={toPhotoUrl(photo.photoAfterUrl ?? photo.photo_after_url)}
description={photo.description}
taskLabel={photo.taskTitle || (photo.taskId ? `#${photo.taskId}` : undefined)}
/>
))
) : (
<p className="col-span-full text-center text-slate-500 py-8">Нет фото отчетов за этот период</p>
)}
</div>
</ContentCard>
</div>
{/* 8. План на следующий месяц */}
<ContentCard
icon={TrendingUp}
title="План работ на следующий месяц"
color="orange"
fullWidth
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{reportData?.planItems && reportData.planItems.length > 0 ? (
reportData.planItems.map((item: any) => (
<PlanItem
key={item.id}
title={item.title}
date={item.deadline ? new Date(item.deadline).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' }) : ''}
status={item.status === 'in_progress' ? 'in-progress' : 'planned'}
/>
))
) : (
<p className="col-span-full text-center text-slate-500 py-8">Нет запланированных работ</p>
)}
</div>
</ContentCard>
</>
);
}
};
// Stat Card Component
interface StatCardProps {
icon: React.ElementType;
title: string;
value: string;
subtitle: string;
trend: string;
color: 'emerald' | 'blue' | 'amber' | 'purple';
details: string;
}
const StatCard: React.FC<StatCardProps> = ({ icon: Icon, title, value, subtitle, trend, color, details }) => {
const colorClasses = {
emerald: 'from-emerald-500 to-emerald-600 bg-emerald-50 text-emerald-600',
blue: 'from-blue-500 to-blue-600 bg-blue-50 text-blue-600',
amber: 'from-amber-500 to-amber-600 bg-amber-50 text-amber-600',
purple: 'from-purple-500 to-purple-600 bg-purple-50 text-purple-600',
};
return (
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200 hover:shadow-xl transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className={`p-3 rounded-xl bg-gradient-to-br ${colorClasses[color]}`}>
<Icon className="w-6 h-6" />
</div>
<span className={`text-xs font-black px-2 py-1 rounded-lg ${
color === 'emerald' ? 'bg-emerald-100 text-emerald-700' :
color === 'blue' ? 'bg-blue-100 text-blue-700' :
color === 'amber' ? 'bg-amber-100 text-amber-700' :
'bg-purple-100 text-purple-700'
}`}>
{trend}
</span>
</div>
<h3 className="text-sm font-bold text-slate-600 mb-2">{title}</h3>
<p className="text-3xl font-black text-slate-800 mb-1">{value}</p>
<p className="text-xs text-slate-500 mb-3">{subtitle}</p>
<p className="text-xs text-slate-400">{details}</p>
</div>
);
};
// Content Card Component
interface ContentCardProps {
icon: React.ElementType;
title: string;
color: string;
fullWidth?: boolean;
children: React.ReactNode;
}
const ContentCard: React.FC<ContentCardProps> = ({ icon: Icon, title, color, fullWidth, children }) => {
const colorClasses: Record<string, string> = {
pink: 'from-pink-500 to-rose-600 bg-pink-50 text-pink-600',
indigo: 'from-indigo-500 to-purple-600 bg-indigo-50 text-indigo-600',
cyan: 'from-cyan-500 to-blue-600 bg-cyan-50 text-cyan-600',
orange: 'from-orange-500 to-red-600 bg-orange-50 text-orange-600',
};
return (
<div className={`bg-white rounded-2xl p-6 shadow-lg border border-slate-200 ${fullWidth ? 'col-span-1 lg:col-span-2' : ''}`}>
<div className="flex items-center gap-3 mb-6">
<div className={`p-3 rounded-xl bg-gradient-to-br ${colorClasses[color] || colorClasses.pink}`}>
<Icon className="w-6 h-6" />
</div>
<h3 className="text-xl font-black text-slate-800">{title}</h3>
</div>
{children}
</div>
);
};
// Event Item Component
const EventItem: React.FC<{ date: string; title: string; description: string }> = ({ date, title, description }) => (
<div className="flex gap-4 p-4 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-pink-500 to-rose-600 rounded-xl flex items-center justify-center text-white font-black text-xs">
{date.split(' ')[0]}
</div>
</div>
<div className="flex-1">
<h4 className="font-bold text-slate-800 mb-1">{title}</h4>
<p className="text-sm text-slate-600">{description}</p>
</div>
</div>
);
// NPS Bar Component
const NPSBar: React.FC<{ label: string; value: number; color: 'emerald' | 'amber' | 'red' }> = ({ label, value, color }) => {
const colorClasses = {
emerald: 'bg-emerald-500',
amber: 'bg-amber-500',
red: 'bg-red-500',
};
return (
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-slate-700">{label}</span>
<span className="text-sm font-black text-slate-800">{value}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full ${colorClasses[color]} rounded-full transition-all duration-500`}
style={{ width: `${value}%` }}
/>
</div>
</div>
);
};
// Photo Comparison Component
const PhotoComparison: React.FC<{
title: string;
date: string;
beforeColor: string;
afterColor: string;
beforeUrl?: string;
afterUrl?: string;
description?: string;
taskLabel?: string;
}> = ({
title,
date,
beforeColor,
afterColor,
beforeUrl,
afterUrl,
description,
taskLabel
}) => (
<div className="bg-slate-50 rounded-xl p-4 hover:shadow-lg transition-shadow">
<h4 className="font-bold text-slate-800 mb-2">{title}</h4>
<p className="text-xs text-slate-500 mb-1">{date}</p>
{taskLabel && (
<p className="text-xs text-slate-500 mb-4">Задача: {taskLabel}</p>
)}
{!taskLabel && <div className="mb-4" />}
<div className="space-y-3">
<div className={`h-32 rounded-lg overflow-hidden flex items-center justify-center ${beforeUrl ? '' : `bg-gradient-to-br ${beforeColor}`}`}>
{beforeUrl ? (
<img src={beforeUrl} alt={`${title} - До`} className="w-full h-full object-cover" />
) : (
<span className="text-white text-xs font-bold">ДО</span>
)}
</div>
<div className={`h-32 rounded-lg overflow-hidden flex items-center justify-center ${afterUrl ? '' : `bg-gradient-to-br ${afterColor}`}`}>
{afterUrl ? (
<img src={afterUrl} alt={`${title} - После`} className="w-full h-full object-cover" />
) : (
<span className="text-white text-xs font-bold">ПОСЛЕ</span>
)}
</div>
</div>
{description && (
<p className="text-xs text-slate-600 mt-2">{description}</p>
)}
</div>
);
// Plan Item Component
const PlanItem: React.FC<{ title: string; date: string; status: 'planned' | 'in-progress' }> = ({ title, date, status }) => (
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors">
<div className={`flex-shrink-0 w-3 h-3 rounded-full mt-2 ${
status === 'in-progress' ? 'bg-blue-500' : 'bg-slate-300'
}`} />
<div className="flex-1">
<h4 className="font-bold text-slate-800 mb-1">{title}</h4>
<p className="text-sm text-slate-600">{date}</p>
{status === 'in-progress' && (
<span className="inline-block mt-2 px-2 py-1 bg-blue-100 text-blue-700 text-xs font-bold rounded">
В работе
</span>
)}
</div>
</div>
);
// Expense Category Component
interface ExpenseCategoryProps {
icon: React.ElementType;
category: string;
amount: number;
total: number;
items: Array<{ name: string; amount: number }>;
color: 'blue' | 'amber' | 'purple' | 'slate';
}
const ExpenseCategory: React.FC<ExpenseCategoryProps> = ({ icon: Icon, category, amount, total, items, color }) => {
const percentage = Math.round((amount / total) * 100);
const colorClasses = {
blue: {
bg: 'bg-blue-50',
icon: 'from-blue-500 to-blue-600 bg-blue-50 text-blue-600',
bar: 'bg-blue-500',
text: 'text-blue-700'
},
amber: {
bg: 'bg-amber-50',
icon: 'from-amber-500 to-amber-600 bg-amber-50 text-amber-600',
bar: 'bg-amber-500',
text: 'text-amber-700'
},
purple: {
bg: 'bg-purple-50',
icon: 'from-purple-500 to-purple-600 bg-purple-50 text-purple-600',
bar: 'bg-purple-500',
text: 'text-purple-700'
},
slate: {
bg: 'bg-slate-50',
icon: 'from-slate-500 to-slate-600 bg-slate-50 text-slate-600',
bar: 'bg-slate-500',
text: 'text-slate-700'
}
};
const colors = colorClasses[color];
return (
<div className={`${colors.bg} rounded-xl p-5 border border-slate-200 hover:shadow-md transition-shadow`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg bg-gradient-to-br ${colors.icon}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<h5 className="font-black text-slate-800 text-base">{category}</h5>
<p className="text-xs text-slate-500 mt-0.5">{percentage}% от общих расходов</p>
</div>
</div>
<div className="text-right">
<p className="text-xl font-black text-slate-800">{amount.toLocaleString('ru-RU')} </p>
</div>
</div>
{/* Progress bar */}
<div className="mb-4">
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden">
<div
className={`h-full ${colors.bar} rounded-full transition-all duration-500`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
{/* Items breakdown */}
<div className="space-y-2 pt-3 border-t border-slate-200">
{items.map((item, index) => {
const itemPercentage = Math.round((item.amount / amount) * 100);
return (
<div key={index} className="flex items-center justify-between text-sm">
<span className="text-slate-700 font-medium">{item.name}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">{itemPercentage}%</span>
<span className="font-bold text-slate-800 min-w-[100px] text-right">
{item.amount.toLocaleString('ru-RU')}
</span>
</div>
</div>
);
})}
</div>
</div>
);
};
// Модальное окно с ключом доступа
interface AccessKeyModalProps {
onClose: () => void;
reportId: string | number;
}
const AccessKeyModal: React.FC<AccessKeyModalProps> = ({ onClose, reportId }) => {
const accessKey = `mkd-${String(reportId)}-key`; // Уникальный ключ на каждый отчёт (демо)
const link = `${window.location.origin}/reports/${reportId}?mode=published&key=${accessKey}`;
const [copied, setCopied] = useState(false);
const [showKey, setShowKey] = useState(false);
const handleCopyLink = () => {
navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopyKey = () => {
navigator.clipboard.writeText(accessKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-lg w-full shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-xl">
<Key className="w-6 h-6 text-indigo-600" />
</div>
<h3 className="text-xl font-black text-slate-800">Ключ доступа к отчету</h3>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ключ доступа
</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<input
type={showKey ? "text" : "password"}
value={accessKey}
readOnly
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 pr-12"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
type="button"
>
{showKey ? <X className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
onClick={handleCopyKey}
className="px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copied ? '✓' : 'Копировать'}
</button>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ссылка на опубликованную версию
</label>
<div className="flex gap-2">
<input
type="text"
value={link}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
onClick={handleCopyLink}
className="px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copied ? '✓' : 'Копировать'}
</button>
</div>
</div>
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-xs text-amber-800 leading-relaxed">
<strong className="font-black">Важно:</strong> Сохраните ключ доступа в безопасном месте.
Он потребуется для просмотра опубликованной версии отчета.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => {
window.open(link, '_blank');
onClose();
}}
className="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Открыть опубликованную версию
</button>
<button
onClick={onClose}
className="px-6 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
</div>
);
};