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 = (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(null); const [companySettings, setCompanySettings] = useState(null); const [isAuthorized, setIsAuthorized] = useState(false); const [accessKeyInput, setAccessKeyInput] = useState(''); const [showAccessKeyModal, setShowAccessKeyModal] = useState(false); // Данные отчета const [reportData, setReportData] = useState(null); const [isLoadingData, setIsLoadingData] = useState(false); // Для published режима: загружаем отчет по ID, чтобы получить buildingId const [loadedBuildingId, setLoadedBuildingId] = useState(buildingIdProp); const [loadedBuildingAddress, setLoadedBuildingAddress] = useState(buildingAddress); const [loadedMonth, setLoadedMonth] = useState(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 = { январ: 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(`/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(`/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(`/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 (

Загрузка данных отчета...

); } 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 (

Доступ к отчету

Введите ключ доступа для просмотра отчета

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(''); } } }} />

Ключ доступа выдается управляющей компанией

); } // Для опубликованной версии - убираем верхнее меню и все элементы управления if (mode === 'published') { return (
{/* Hero Section - Building Photo */}

{loadedBuildingAddress || buildingAddress}

Ежемесячный отчет для собственников

{/* Month Navigation - только для опубликованной версии */}

{currentMonth}

{/* Archive Calendar Dropdown */} {showArchiveCalendar && ( <>
{ setShowArchiveCalendar(false); setSelectedYear(null); }} />

Выберите период

{selectedYear === null ? (

Выберите год

{archiveYears.map(year => ( ))}
) : (

Выберите месяц {selectedYear}

{months.map((monthName, index) => ( ))}
)}
)}
{/* Rest of content - same as portal version */} {renderContent()}
); } // Версия для портала (редактирование) return (
{/* Header with back button and action buttons */} {onBack && (
)}
{/* Модальное окно с ключом доступа */} {showAccessKeyModal && ( setShowAccessKeyModal(false)} reportId={reportId || 'demo'} /> )} {/* Hero Section - Building Photo */}

{loadedBuildingAddress || buildingAddress}

Ежемесячный отчет для собственников

{/* Month Navigation - для портала */}

{currentMonth}

{/* Render main content */} {renderContent()}
); // Функция для рендеринга основного контента (используется в обеих версиях) function renderContent() { const company = companySettings || reportData?.company; return ( <> {/* Company Info & Logo Section */} {company && (
{company.logoUrl ? ( {company.name} ) : (
)}

{company.name}

{company.address && (

{company.address}

)}

{company.phone && `Телефон: ${company.phone}`} {company.phone && company.email && ' • '} {company.email && `Email: ${company.email}`}

{company.licenseNumber && (

{company.licenseNumber}

{company.licenseValidUntil && (

Действительна до {new Date(company.licenseValidUntil).toLocaleDateString('ru-RU')}

)}
)}
)} {/* Stats Grid */}
{/* 1. Качество отработок по заявкам */} {/* 2. Количество задач из календарного плана */} {/* 3. Количество собранных денег */} {/* 4. Количество долгов и выигранных дел */}
{/* Расходы по дому */}
{/* Общая информация */}

Собрано средств

{reportData?.stats?.fundsCollected ? reportData.stats.fundsCollected.toLocaleString('ru-RU') : '0'} ₽

Потрачено

{reportData?.expenses?.total ? reportData.expenses.total.toLocaleString('ru-RU') : '0'} ₽

Остаток

{reportData?.stats?.fundsBalance ? reportData.stats.fundsBalance.toLocaleString('ru-RU') : '0'} ₽

{/* Расходы по категориям */}

Распределение расходов

{reportData?.expenses?.byCategory && ( <> {reportData.expenses.byCategory.utilities > 0 && ( ['электроэнергия', 'электричество', 'водоснабжение', 'водоотведение', 'отопление', 'газ'].some(kw => name.toLowerCase().includes(kw))) .map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))} color="blue" /> )} {reportData.expenses.byCategory.maintenance > 0 && ( ['ремонт', 'содержание', 'лифт', 'уборка', 'мусор', 'материалы'].some(kw => name.toLowerCase().includes(kw))) .map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))} color="amber" /> )} {reportData.expenses.byCategory.management > 0 && ( ['зарплата', 'персонал', 'административные', 'страхование', 'управление'].some(kw => name.toLowerCase().includes(kw))) .map(([name, amount]: [string, any]) => ({ name, amount: parseFloat(amount) || 0 }))} color="purple" /> )} {reportData.expenses.byCategory.other > 0 && ( !['электроэнергия', 'электричество', 'водоснабжение', 'водоотведение', 'отопление', 'газ', 'ремонт', 'содержание', 'лифт', 'уборка', 'мусор', 'материалы', 'зарплата', 'персонал', 'административные', 'страхование', 'управление'].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) && (

Нет данных о расходах за этот период

)}
{/* Примечание */}

Важно: Все расходы подтверждены документами и соответствуют тарифам, утвержденным на общем собрании собственников. Детальная информация по каждой статье расходов доступна в управляющей компании по предварительной записи.

{/* Main Content Grid */}
{/* 5. Мероприятия */}
{reportData?.events && reportData.events.length > 0 ? ( reportData.events.map((event: any, idx: number) => ( )) ) : (

Нет мероприятий за этот период

)}
{/* 6. NPS */}
{reportData?.nps?.score ?? 0}
NPS

{reportData?.nps?.score !== undefined ? reportData.nps.score >= 50 ? 'Отличный показатель' : reportData.nps.score >= 0 ? 'Хороший показатель' : 'Требует внимания' : 'Нет данных'}

{reportData?.nps?.totalResponses ? (

Всего ответов: {reportData.nps.totalResponses}

) : (

Нет ответов за этот период

)}
{reportData?.nps && reportData.nps.totalResponses > 0 && (
)}
{/* 7. Фото отчет задач до/после */}
{reportData?.workPhotos && reportData.workPhotos.length > 0 ? ( reportData.workPhotos.map((photo: any) => ( )) ) : (

Нет фото отчетов за этот период

)}
{/* 8. План на следующий месяц */}
{reportData?.planItems && reportData.planItems.length > 0 ? ( reportData.planItems.map((item: any) => ( )) ) : (

Нет запланированных работ

)}
); } }; // 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 = ({ 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 (
{trend}

{title}

{value}

{subtitle}

{details}

); }; // Content Card Component interface ContentCardProps { icon: React.ElementType; title: string; color: string; fullWidth?: boolean; children: React.ReactNode; } const ContentCard: React.FC = ({ icon: Icon, title, color, fullWidth, children }) => { const colorClasses: Record = { 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 (

{title}

{children}
); }; // Event Item Component const EventItem: React.FC<{ date: string; title: string; description: string }> = ({ date, title, description }) => (
{date.split(' ')[0]}

{title}

{description}

); // 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 (
{label} {value}%
); }; // 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 }) => (

{title}

{date}

{taskLabel && (

Задача: {taskLabel}

)} {!taskLabel &&
}
{beforeUrl ? ( {`${title} ) : ( ДО )}
{afterUrl ? ( {`${title} ) : ( ПОСЛЕ )}
{description && (

{description}

)}
); // Plan Item Component const PlanItem: React.FC<{ title: string; date: string; status: 'planned' | 'in-progress' }> = ({ title, date, status }) => (

{title}

{date}

{status === 'in-progress' && ( В работе )}
); // 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 = ({ 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 (
{category}

{percentage}% от общих расходов

{amount.toLocaleString('ru-RU')} ₽

{/* Progress bar */}
{/* Items breakdown */}
{items.map((item, index) => { const itemPercentage = Math.round((item.amount / amount) * 100); return (
{item.name}
{itemPercentage}% {item.amount.toLocaleString('ru-RU')} ₽
); })}
); }; // Модальное окно с ключом доступа interface AccessKeyModalProps { onClose: () => void; reportId: string | number; } const AccessKeyModal: React.FC = ({ 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 (

Ключ доступа к отчету

Важно: Сохраните ключ доступа в безопасном месте. Он потребуется для просмотра опубликованной версии отчета.

); };