Files
mkd/components/pr/ResidentReports.tsx

700 lines
29 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { ResidentReport, Building, WorkPhoto } from '../../types';
import { apiClient } from '../../services/apiClient';
import { FileText, Sparkles, Send, Download, Eye, CheckCircle2, History, Bot, X, Loader2, Calendar, Link2, Key } from 'lucide-react';
import { BuildingReportPage } from './BuildingReportPage';
export const ResidentReports: React.FC = () => {
const [reports, setReports] = useState<ResidentReport[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedReportId, setSelectedReportId] = useState<string | number | null>(null);
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
useEffect(() => {
loadReports();
}, []);
const loadReports = async () => {
try {
setIsLoading(true);
const data = await apiClient.get<ResidentReport[]>('/pr/reports');
setReports(data);
} catch (err) {
console.error('Error loading reports:', err);
setReports([]);
} finally {
setIsLoading(false);
}
};
const handlePublish = async (reportId: string | number) => {
try {
// При публикации отчет обновляется актуальными данными
await apiClient.post(`/pr/reports/${reportId}/publish`);
await loadReports();
alert('Отчет обновлен актуальными данными и опубликован');
} catch (err: any) {
console.error('Error publishing report:', err);
alert(`Ошибка публикации отчета: ${err.message || 'Неизвестная ошибка'}`);
}
};
// Если выбран отчет для просмотра, показываем страницу отчета
if (selectedReportId) {
// Для демо используем BuildingReportPage
if (selectedReportId === 'demo') {
return (
<BuildingReportPage
buildingAddress="Кавказская, 12"
month="Январь 2025"
onBack={() => setSelectedReportId(null)}
mode="portal"
reportId="demo"
/>
);
}
// Для реальных отчетов используем BuildingReportPage с данными из БД
const report = reports.find(r => r.id === selectedReportId);
if (report) {
// Определяем текущий месяц из отчета или используем текущий месяц
const now = new Date();
const currentMonthName = now.toLocaleDateString('ru-RU', { month: 'long' });
const currentYear = now.getFullYear();
const initialMonth = report.month || `${currentMonthName} ${currentYear}`;
return (
<BuildingReportPage
buildingId={report.buildingId}
buildingAddress={report.address || `Отчет #${report.id}`}
month={initialMonth}
onBack={() => setSelectedReportId(null)}
mode="portal"
reportId={report.buildingId} // Используем buildingId для загрузки данных по месяцам
/>
);
}
// Fallback, если отчет не найден
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-slate-600 mb-4">Отчет не найден</p>
<button
onClick={() => setSelectedReportId(null)}
className="px-4 py-2 bg-primary-600 text-white rounded-xl"
>
Вернуться к списку
</button>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Реестр ежемесячных отчетов</h3>
<p className="text-xs text-slate-400 mt-1">Отчеты создаются автоматически 1 числа каждого месяца</p>
</div>
</div>
{/* List of Reports */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : (
<div className="space-y-4">
{reports.map(report => (
<div key={report.id} className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm flex flex-col md:flex-row justify-between gap-6 hover:border-primary-200 transition-all group">
<div className="flex items-center gap-4 flex-1">
<div className={`p-4 rounded-3xl ${report.status === 'published' ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
<FileText className="w-8 h-8"/>
</div>
<div>
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">
{report.address || `Отчет #${report.id}`}
</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">
{report.month} {report.status === 'published' ? 'Опубликован' : 'Черновик'}
</p>
{(report.liveStats || (report.content && typeof report.content === 'object')) && (
<div className="flex gap-4 mt-4">
<ReportStat
label="Заявок"
value={report.liveStats?.applicationsTotal ?? report.content?.applications?.total ?? 0}
/>
<ReportStat
label="NPS"
value={report.liveStats?.npsScore ?? (report.content as any)?.nps?.score ?? report.content?.statistics?.nps ?? 0}
/>
<ReportStat
label="Собрано"
value={(() => {
const collected = report.liveStats?.fundsCollected ?? (report.content as any)?.finances?.collected ?? report.content?.finances?.income ?? 0;
return `${(collected / 1000000).toFixed(1)}M ₽`;
})()}
/>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-2 border-t md:border-t-0 border-slate-50 pt-4 md:pt-0">
<button
onClick={() => setSelectedReportId(report.id)}
className="p-3 bg-slate-50 text-slate-500 rounded-2xl hover:bg-slate-100"
title="Открыть отчет"
>
<Eye className="w-5 h-5"/>
</button>
<button className="p-3 bg-slate-50 text-slate-500 rounded-2xl hover:bg-slate-100" title="Скачать PDF">
<Download className="w-5 h-5"/>
</button>
{report.status === 'draft' ? (
<button
onClick={() => handlePublish(report.id)}
className="bg-primary-600 text-white px-5 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-lg flex items-center gap-2 ml-2 active:scale-95 transition-all"
title="Обновить данные и опубликовать отчет"
>
<Send className="w-4 h-4"/> Опубликовать
</button>
) : (
<div className="bg-emerald-50 text-emerald-600 px-5 py-3 rounded-2xl text-[10px] font-black uppercase flex items-center gap-2 border border-emerald-100 ml-2">
<CheckCircle2 className="w-4 h-4"/> Опубликован
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && (
<AccessKeyModal
onClose={() => setShowAccessKeyModal(false)}
reportId="demo"
/>
)}
</div>
);
};
const ReportStat = ({ label, value }: { label: string; value: string | number }) => (
<div className="text-left">
<p className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{label}</p>
<p className="text-xs font-black text-slate-700">{value}</p>
</div>
);
interface ReportCreateFormProps {
onClose: () => void;
onSuccess: () => void;
selectedBuilding: Building | null;
onBuildingSelect: (building: Building | null) => void;
}
const ReportCreateForm: React.FC<ReportCreateFormProps> = ({ onClose, onSuccess, selectedBuilding, onBuildingSelect }) => {
const [buildings, setBuildings] = useState<Building[]>([]);
const [formData, setFormData] = useState({
building_id: '',
month: '',
period_start: '',
period_end: '',
createForAll: false, // Создать для всех домов
selectedBuildings: [] as string[] // Выбранные дома для массового создания
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [reportData, setReportData] = useState<any>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [creationProgress, setCreationProgress] = useState<{
total: number;
completed: number;
current: string;
} | null>(null);
useEffect(() => {
loadBuildings();
}, []);
useEffect(() => {
if (formData.building_id && formData.period_start && formData.period_end) {
loadReportData();
}
}, [formData.building_id, formData.period_start, formData.period_end]);
const loadBuildings = async () => {
try {
const data = await apiClient.get<Building[]>('/buildings');
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
}
};
const loadReportData = async () => {
try {
setIsGenerating(true);
// Загружаем данные для отчета
const building = buildings.find(b => b.id === formData.building_id);
if (!building) return;
// Заявки
const applications = await apiClient.get(`/applications`).catch(() => []);
// Финансовые данные
const financialData = await apiClient.get(`/finance/buildings/${formData.building_id}/summary`).catch(() => null);
// Фото отчеты
const workPhotos = await apiClient.get<WorkPhoto[]>(`/pr/work-photos?building_id=${formData.building_id}`).catch(() => []);
setReportData({
building,
applications: Array.isArray(applications) ? applications : [],
financialData,
workPhotos: Array.isArray(workPhotos) ? workPhotos : []
});
} catch (err) {
console.error('Error loading report data:', err);
} finally {
setIsGenerating(false);
}
};
const handleGenerate = async () => {
if (!formData.period_start || !formData.period_end) {
alert('Заполните период отчета');
return;
}
if (!formData.createForAll && !formData.building_id) {
alert('Выберите дом или включите создание для всех домов');
return;
}
try {
setIsGenerating(true);
setIsSubmitting(true);
if (formData.createForAll) {
// Массовое создание для всех домов
const targetBuildings = formData.selectedBuildings.length > 0
? formData.selectedBuildings
: buildings.map(b => b.id);
setCreationProgress({ total: targetBuildings.length, completed: 0, current: 'Начало создания...' });
const result = await apiClient.post<{
success: boolean;
reportsCreated: number;
reports: Array<{ buildingId: string; reportId: number; updated: boolean }>;
}>('/pr/reports/bulk-create', {
month: formData.month,
period_start: formData.period_start,
period_end: formData.period_end,
building_ids: targetBuildings
});
setCreationProgress({
total: targetBuildings.length,
completed: result.reportsCreated,
current: 'Завершено'
});
setTimeout(() => {
setCreationProgress(null);
alert(`Успешно создано отчетов: ${result.reportsCreated} из ${targetBuildings.length}`);
onSuccess();
}, 1000);
} else {
// Создание для одного дома
const report = await apiClient.post<ResidentReport>('/pr/reports', {
building_id: formData.building_id,
month: formData.month,
period_start: formData.period_start,
period_end: formData.period_end
});
// Генерируем контент
await apiClient.post(`/pr/reports/${report.id}/generate`);
onSuccess();
}
} catch (err: any) {
console.error('Error creating report:', err);
alert(`Ошибка создания отчета: ${err.message || 'Неизвестная ошибка'}`);
} finally {
setIsGenerating(false);
setIsSubmitting(false);
setCreationProgress(null);
}
};
const selectedBuildingData = buildings.find(b => b.id === formData.building_id);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-white rounded-3xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto my-8">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">Создать отчет жителям</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="space-y-6">
{/* Опция создания для всех домов */}
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.createForAll}
onChange={e => setFormData({
...formData,
createForAll: e.target.checked,
building_id: e.target.checked ? '' : formData.building_id
})}
className="w-5 h-5 text-indigo-600 rounded focus:ring-indigo-500"
/>
<div>
<span className="font-black text-slate-800">Создать отчеты для всех домов</span>
<p className="text-xs text-slate-600 mt-1">
Автоматически создаст отчеты для всех домов за указанный период
</p>
</div>
</label>
</div>
{/* Форма выбора дома и периода */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{!formData.createForAll ? (
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дом
</label>
<select
value={formData.building_id}
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required={!formData.createForAll}
>
<option value="">Выберите дом</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>
{b.passport?.address || b.id}
</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Выбрать дома (оставьте пустым для всех)
</label>
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-xl p-3 space-y-2">
{buildings.map(b => (
<label key={b.id} className="flex items-center gap-2 cursor-pointer hover:bg-slate-50 p-2 rounded">
<input
type="checkbox"
checked={formData.selectedBuildings.includes(b.id)}
onChange={e => {
if (e.target.checked) {
setFormData({
...formData,
selectedBuildings: [...formData.selectedBuildings, b.id]
});
} else {
setFormData({
...formData,
selectedBuildings: formData.selectedBuildings.filter(id => id !== b.id)
});
}
}}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-slate-700">{b.passport?.address || b.id}</span>
</label>
))}
</div>
<p className="text-xs text-slate-500 mt-2">
Выбрано: {formData.selectedBuildings.length || 'Все'} домов
</p>
</div>
)}
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Месяц (например: Май 2024)
</label>
<input
type="text"
value={formData.month}
onChange={e => setFormData({ ...formData, month: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
placeholder="Май 2024"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Период начала
</label>
<input
type="date"
value={formData.period_start}
onChange={e => setFormData({ ...formData, period_start: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Период окончания
</label>
<input
type="date"
value={formData.period_end}
onChange={e => setFormData({ ...formData, period_end: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
</div>
{/* Прогресс создания */}
{creationProgress && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-black text-slate-800">Создание отчетов</h4>
<span className="text-sm font-bold text-indigo-600">
{creationProgress.completed} / {creationProgress.total}
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 mb-2">
<div
className="bg-indigo-600 h-3 rounded-full transition-all duration-300"
style={{ width: `${(creationProgress.completed / creationProgress.total) * 100}%` }}
/>
</div>
{creationProgress.current && (
<p className="text-xs text-slate-600 mt-2">
Обрабатывается: {creationProgress.current}
</p>
)}
</div>
)}
{/* Данные отчета */}
{isGenerating && !creationProgress && (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
</div>
)}
{reportData && !isGenerating && (
<div className="space-y-4 border-t pt-6">
<h4 className="font-black text-slate-800 text-lg">Данные для отчета</h4>
{/* Информация о доме */}
{reportData.building && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Информация о доме</h5>
<p className="text-sm text-slate-600">
{reportData.building.passport?.address || 'Адрес не указан'}
</p>
{reportData.building.passport?.apartmentsCount && (
<p className="text-xs text-slate-500 mt-1">
Квартир: {reportData.building.passport.apartmentsCount}
</p>
)}
</div>
)}
{/* Заявки */}
{reportData.applications && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Заявки за период</h5>
<p className="text-sm text-slate-600">
Всего: {reportData.applications.length}
</p>
</div>
)}
{/* Финансы */}
{reportData.financialData && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Финансы</h5>
<p className="text-sm text-slate-600">
Доходы: {reportData.financialData.totalIncome || 0}
</p>
<p className="text-sm text-slate-600">
Расходы: {reportData.financialData.totalExpenses || 0}
</p>
</div>
)}
{/* Фото отчеты */}
{reportData.workPhotos && reportData.workPhotos.length > 0 && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Фото отчеты</h5>
<p className="text-sm text-slate-600">
Работ: {reportData.workPhotos.length}
</p>
</div>
)}
</div>
)}
{/* Кнопки */}
<div className="flex gap-3 pt-4 border-t">
<button
onClick={handleGenerate}
disabled={isSubmitting || isGenerating || (!formData.createForAll && !formData.building_id) || !formData.period_start || !formData.period_end}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
>
{isSubmitting || isGenerating
? (formData.createForAll ? `Создание отчетов...` : 'Создание...')
: (formData.createForAll ? `Создать отчеты для всех домов` : 'Создать отчет')}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
>
Отмена
</button>
</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>
);
};