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

700 lines
29 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 { 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>
);
};