import React, { useState, useMemo, useEffect } from 'react'; import { ExternalLink } from 'lucide-react'; import { Review, AIAnalysisResult, AnalyzedFeedback } from '../../types'; import { analyzeResidentFeedback } from '../../services/geminiService'; import { apiClient } from '../../services/apiClient'; import { Smile, TrendingUp, TrendingDown, Sparkles, Bot, Filter, ThumbsUp, ThumbsDown, MessageSquare, Loader2, Search, Archive, AlertCircle, CheckCircle2, Plus, X } from 'lucide-react'; import { Building } from '../../types'; import { backendApi } from '../../services/apiClient'; export const PRFeedbackFeed: React.FC = () => { const [reviews, setReviews] = useState([]); const [aiResult, setAiResult] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isLoadingReviews, setIsLoadingReviews] = useState(true); const [reviewsServiceUnavailable, setReviewsServiceUnavailable] = useState(false); const [search, setSearch] = useState(''); const [filterSource, setFilterSource] = useState(''); const [filterStatus, setFilterStatus] = useState(''); const [filterBuilding, setFilterBuilding] = useState(''); const [filterRating, setFilterRating] = useState(''); const [showCreateForm, setShowCreateForm] = useState(false); const [fetchSource, setFetchSource] = useState<'yandex_maps' | '2gis' | ''>(''); const [isFetching, setIsFetching] = useState(false); const [stats, setStats] = useState(null); const [buildings, setBuildings] = useState([]); const [isLoadingStats, setIsLoadingStats] = useState(false); // Загрузка отзывов useEffect(() => { loadReviews(); loadStats(); }, [filterSource, filterStatus, filterBuilding]); useEffect(() => { loadBuildings(); }, []); const loadBuildings = async () => { try { const data = await backendApi.getBuildings(); setBuildings(data); } catch (err) { console.error('Error loading buildings:', err); setBuildings([]); } }; const loadStats = async () => { try { setIsLoadingStats(true); const params = new URLSearchParams(); if (filterBuilding) params.append('building_id', filterBuilding); const queryString = params.toString(); const path = `/pr/reviews/stats${queryString ? `?${queryString}` : ''}`; const data = await apiClient.get(path); setStats(data); } catch (err) { console.error('Error loading stats:', err); setStats(null); } finally { setIsLoadingStats(false); } }; const loadReviews = async () => { try { setIsLoadingReviews(true); setReviewsServiceUnavailable(false); const params = new URLSearchParams(); if (filterSource) params.append('source', filterSource); if (filterStatus) params.append('status', filterStatus); if (filterBuilding) params.append('building_id', filterBuilding); const queryString = params.toString(); const path = `/pr/reviews${queryString ? `?${queryString}` : ''}`; const data = await apiClient.get(path); setReviews(Array.isArray(data) ? data : []); } catch (err: any) { setReviews([]); setReviewsServiceUnavailable(true); if (err?.status !== 500 && !err?.message?.includes('fetch')) { console.warn('Reviews service:', err?.message || err); } } finally { setIsLoadingReviews(false); } }; const handleAnalyze = async () => { setIsLoading(true); setAiResult(null); // Анализируем только отфильтрованные отзывы const filteredReviews = reviews.filter(r => { // Фильтр по рейтингу if (filterRating === 'negative' && r.rating > 3) return false; if (filterRating === 'neutral' && (r.rating <= 3 || r.rating >= 8)) return false; if (filterRating === 'positive' && r.rating < 8) return false; return true; }); // Преобразуем Review в ResidentFeedback для анализа const feedback = filteredReviews.map(r => ({ id: String(r.id), buildingId: r.buildingId, address: r.address || '', date: r.date, text: r.text, source: r.source, rating: r.rating })); const result = await analyzeResidentFeedback(feedback); setAiResult(result); setIsLoading(false); }; const handleStatusChange = async (reviewId: number, status: 'processed' | 'archived') => { try { await apiClient.put(`/pr/reviews/${reviewId}/status`, { status }); await loadReviews(); } catch (err) { console.error('Error updating review status:', err); alert('Ошибка обновления статуса отзыва'); } }; const handleCreateIncident = async (reviewId: number) => { try { await apiClient.post(`/pr/incidents/from-review/${reviewId}`, { created_by: 'Current User' // TODO: получить из контекста пользователя }); await loadReviews(); alert('Инцидент создан успешно'); } catch (err) { console.error('Error creating incident:', err); alert('Ошибка создания инцидента'); } }; const handleFetchReviews = async () => { if (!fetchSource) { alert('Выберите источник для загрузки'); return; } try { setIsFetching(true); const result = await apiClient.post<{ success: boolean; parsed: number; found: number; message?: string; details?: string }>( '/pr/reviews/fetch', { source: fetchSource } ); await loadReviews(); const msg = result.message || `Загружено отзывов: ${result.parsed ?? 0}`; alert(msg); } catch (err: any) { const status = err?.status ?? err?.response?.status; const details = err?.details ?? err?.response?.data?.details ?? err?.message; if (status === 400) { alert('Укажите API ключ в Настройках → Интеграции'); } else { alert(details || 'Ошибка загрузки отзывов'); } } finally { setIsFetching(false); } }; const analyzedFeedback = useMemo((): (AnalyzedFeedback & Review)[] => { // Применяем фильтры let filtered = reviews; // Фильтр по рейтингу if (filterRating === 'negative') { filtered = filtered.filter(r => r.rating <= 3); } else if (filterRating === 'neutral') { filtered = filtered.filter(r => r.rating > 3 && r.rating < 8); } else if (filterRating === 'positive') { filtered = filtered.filter(r => r.rating >= 8); } // Поиск if (search) { filtered = filtered.filter(r => (r.address || '').toLowerCase().includes(search.toLowerCase()) || r.text.toLowerCase().includes(search.toLowerCase()) || (r.authorName || '').toLowerCase().includes(search.toLowerCase()) ); } const raw = aiResult ? aiResult.analyzedFeedback.map(af => { // Находим соответствующий review для сохранения всех полей const review = filtered.find(r => String(r.id) === af.id); return { ...af, ...review, id: af.id }; }) : filtered.map(r => ({ id: String(r.id), buildingId: r.buildingId, address: r.address || '', date: r.date, text: r.text, source: r.source, rating: r.rating, category: 'Неизвестно', sentiment: r.rating >= 8 ? 'Positive' as const : r.rating <= 3 ? 'Negative' as const : 'Neutral' as const, // Сохраняем все поля Review authorName: r.authorName, sourceUrl: r.sourceUrl, status: r.status, processedAt: r.processedAt, processedBy: r.processedBy, createdAt: r.createdAt, updatedAt: r.updatedAt })); return raw; }, [reviews, aiResult, search, filterRating]); return (
{/* Statistics Card */} {stats && !isLoadingStats && (

Статистика отзывов

{stats.total || 0}

Всего

{stats.new_count || 0}

Новых

{stats.processed_count || 0}

Обработано

{stats.avg_rating ? parseFloat(stats.avg_rating).toFixed(1) : '0.0'}

Средний рейтинг

{stats.positive_count || 0}

Положительных

{stats.negative_count || 0}

Негативных

{stats.yandex_count || 0}

Яндекс Карты

{stats.gis2_count || 0}

2ГИС

)} {/* Поиск и фильтры — сверху */}
setSearch(e.target.value)} className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm" />
{/* Кнопки — под фильтрами, по правому краю */}
{/* AI Summary Card */} {aiResult && !isLoading && (

Сводный отчет

Главные плюсы:
    {aiResult.summary.positive.map((item, i) =>
  • {item}
  • )}
Болевые точки:
    {aiResult.summary.negative.map((item, i) =>
  • {item}
  • )}
)} {/* Create Review Form */} {showCreateForm && ( setShowCreateForm(false)} onSuccess={() => { setShowCreateForm(false); loadReviews(); }} /> )} {/* Feedback List */} {isLoadingReviews ? (
) : reviewsServiceUnavailable ? (

Сервис отзывов временно недоступен

Проверьте подключение к n8n или повторите позже

) : analyzedFeedback.length === 0 ? (

Отзывы не найдены

) : (
{analyzedFeedback.map(fb => ( ))}
)}
); }; // Функция для получения читаемого названия источника const getSourceName = (source: string) => { const sourceNames: Record = { 'yandex_maps': 'Яндекс Карты', '2gis': '2ГИС', 'internal': 'Внутренний', 'other': 'Другое' }; return sourceNames[source] || source; }; interface FeedbackCardProps { feedback: AnalyzedFeedback; onStatusChange: (reviewId: number, status: 'processed' | 'archived') => void; onCreateIncident: (reviewId: number) => void; } const FeedbackCard: React.FC = ({ feedback, onStatusChange, onCreateIncident }) => { const [incident, setIncident] = useState(null); const [isLoadingIncident, setIsLoadingIncident] = useState(false); const sentimentConfig = { Positive: { icon: ThumbsUp, color: 'text-emerald-500', bg: 'bg-emerald-50' }, Negative: { icon: ThumbsDown, color: 'text-red-500', bg: 'bg-red-50' }, Neutral: { icon: MessageSquare, color: 'text-slate-500', bg: 'bg-slate-50' }, }; const config = sentimentConfig[feedback.sentiment]; const Icon = config.icon; // Определяем статус из review (если есть) const review = feedback as any; const status = review.status || 'new'; const isNegative = feedback.rating <= 3; const authorName = review.authorName || review.author_name; const sourceUrl = review.sourceUrl || review.source_url; const processedAt = review.processedAt || review.processed_at; const processedBy = review.processedBy || review.processed_by; // Загружаем инцидент, если есть useEffect(() => { const loadIncident = async () => { try { setIsLoadingIncident(true); const incidents = await apiClient.get(`/pr/incidents?review_id=${Number(feedback.id)}`); if (incidents && incidents.length > 0) { setIncident(incidents[0]); } } catch (err) { // Игнорируем ошибки загрузки инцидента console.warn('Error loading incident:', err); } finally { setIsLoadingIncident(false); } }; if (feedback.id && status === 'processed') { loadIncident(); } }, [feedback.id, status]); return (
{feedback.address}

{feedback.date} • {getSourceName(feedback.source)} {authorName && ` • ${authorName}`} {status === 'processed' && ' • Обработан'} {status === 'archived' && ' • В архиве'} {processedAt && ` • ${new Date(processedAt).toLocaleDateString('ru-RU')}`} {processedBy && ` • ${processedBy}`}

{feedback.category} {feedback.rating}/10

«{feedback.text}»

{/* Индикатор инцидента */} {incident && ( )} {/* Ссылка на оригинал */} {sourceUrl && ( )} {/* Действия */}
{status === 'new' && ( <> {isNegative && ( )} )} {status === 'processed' && ( )}
); }; interface ReviewCreateFormProps { onClose: () => void; onSuccess: () => void; } const ReviewCreateForm: React.FC = ({ onClose, onSuccess }) => { const [formData, setFormData] = useState({ building_id: '', source: 'internal' as Review['source'], source_url: '', author_name: '', text: '', rating: 5, date: new Date().toISOString().split('T')[0] }); const [buildings, setBuildings] = useState([]); const [isLoadingBuildings, setIsLoadingBuildings] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { loadBuildings(); }, []); const loadBuildings = async () => { try { setIsLoadingBuildings(true); const data = await backendApi.getBuildings(); setBuildings(data); } catch (err) { console.error('Error loading buildings:', err); setBuildings([]); } finally { setIsLoadingBuildings(false); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.building_id || !formData.text || !formData.date) { alert('Заполните обязательные поля'); return; } try { setIsSubmitting(true); await apiClient.post('/pr/reviews', formData); onSuccess(); } catch (err) { console.error('Error creating review:', err); alert('Ошибка создания отзыва'); } finally { setIsSubmitting(false); } }; return (

Добавить отзыв

{isLoadingBuildings ? (
Загрузка домов...
) : ( )}
setFormData({ ...formData, source_url: e.target.value })} placeholder="https://yandex.ru/maps/org/..." className="w-full p-3 border border-slate-200 rounded-xl text-sm" />
setFormData({ ...formData, author_name: e.target.value })} placeholder="Имя автора отзыва" className="w-full p-3 border border-slate-200 rounded-xl text-sm" />