813 lines
35 KiB
TypeScript
Executable File
813 lines
35 KiB
TypeScript
Executable File
|
||
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<Review[]>([]);
|
||
const [aiResult, setAiResult] = useState<AIAnalysisResult | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isLoadingReviews, setIsLoadingReviews] = useState(true);
|
||
const [reviewsServiceUnavailable, setReviewsServiceUnavailable] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const [filterSource, setFilterSource] = useState<string>('');
|
||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||
const [filterBuilding, setFilterBuilding] = useState<string>('');
|
||
const [filterRating, setFilterRating] = useState<string>('');
|
||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||
const [fetchSource, setFetchSource] = useState<'yandex_maps' | '2gis' | ''>('');
|
||
const [isFetching, setIsFetching] = useState(false);
|
||
const [stats, setStats] = useState<any>(null);
|
||
const [buildings, setBuildings] = useState<Building[]>([]);
|
||
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<any>(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<Review[]>(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 (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Statistics Card */}
|
||
{stats && !isLoadingStats && (
|
||
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
|
||
<h4 className="font-bold text-slate-800 mb-4">Статистика отзывов</h4>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="text-center p-4 bg-slate-50 rounded-xl">
|
||
<p className="text-2xl font-black text-slate-800">{stats.total || 0}</p>
|
||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider mt-1">Всего</p>
|
||
</div>
|
||
<div className="text-center p-4 bg-blue-50 rounded-xl">
|
||
<p className="text-2xl font-black text-blue-600">{stats.new_count || 0}</p>
|
||
<p className="text-xs text-blue-600 font-bold uppercase tracking-wider mt-1">Новых</p>
|
||
</div>
|
||
<div className="text-center p-4 bg-emerald-50 rounded-xl">
|
||
<p className="text-2xl font-black text-emerald-600">{stats.processed_count || 0}</p>
|
||
<p className="text-xs text-emerald-600 font-bold uppercase tracking-wider mt-1">Обработано</p>
|
||
</div>
|
||
<div className="text-center p-4 bg-amber-50 rounded-xl">
|
||
<p className="text-2xl font-black text-amber-600">{stats.avg_rating ? parseFloat(stats.avg_rating).toFixed(1) : '0.0'}</p>
|
||
<p className="text-xs text-amber-600 font-bold uppercase tracking-wider mt-1">Средний рейтинг</p>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||
<div className="text-center p-3 bg-emerald-50 rounded-lg">
|
||
<p className="text-lg font-black text-emerald-600">{stats.positive_count || 0}</p>
|
||
<p className="text-[10px] text-emerald-600 font-bold uppercase">Положительных</p>
|
||
</div>
|
||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||
<p className="text-lg font-black text-red-600">{stats.negative_count || 0}</p>
|
||
<p className="text-[10px] text-red-600 font-bold uppercase">Негативных</p>
|
||
</div>
|
||
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||
<p className="text-lg font-black text-blue-600">{stats.yandex_count || 0}</p>
|
||
<p className="text-[10px] text-blue-600 font-bold uppercase">Яндекс Карты</p>
|
||
</div>
|
||
<div className="text-center p-3 bg-purple-50 rounded-lg">
|
||
<p className="text-lg font-black text-purple-600">{stats.gis2_count || 0}</p>
|
||
<p className="text-[10px] text-purple-600 font-bold uppercase">2ГИС</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Поиск и фильтры — сверху */}
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск по отзывам и адресам..."
|
||
value={search}
|
||
onChange={e => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2 flex-wrap">
|
||
<select
|
||
value={filterSource}
|
||
onChange={e => setFilterSource(e.target.value)}
|
||
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
|
||
>
|
||
<option value="">Все источники</option>
|
||
<option value="yandex_maps">Яндекс Карты</option>
|
||
<option value="2gis">2ГИС</option>
|
||
<option value="internal">Внутренний</option>
|
||
<option value="other">Другое</option>
|
||
</select>
|
||
<select
|
||
value={filterStatus}
|
||
onChange={e => setFilterStatus(e.target.value)}
|
||
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
|
||
>
|
||
<option value="">Все статусы</option>
|
||
<option value="new">Новые</option>
|
||
<option value="processed">Обработанные</option>
|
||
<option value="archived">Архив</option>
|
||
</select>
|
||
<select
|
||
value={filterBuilding}
|
||
onChange={e => setFilterBuilding(e.target.value)}
|
||
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
|
||
>
|
||
<option value="">Все здания</option>
|
||
{buildings.map(building => (
|
||
<option key={building.id} value={building.id}>
|
||
{building.passport?.address || building.id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={filterRating}
|
||
onChange={e => setFilterRating(e.target.value)}
|
||
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
|
||
>
|
||
<option value="">Все рейтинги</option>
|
||
<option value="negative">Негативные (1-3)</option>
|
||
<option value="neutral">Нейтральные (4-7)</option>
|
||
<option value="positive">Положительные (8-10)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{/* Кнопки — под фильтрами, по правому краю */}
|
||
<div className="flex gap-3 flex-wrap justify-end items-center">
|
||
<select
|
||
value={fetchSource}
|
||
onChange={e => setFetchSource(e.target.value as 'yandex_maps' | '2gis' | '')}
|
||
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
|
||
disabled={isFetching}
|
||
>
|
||
<option value="">Источник</option>
|
||
<option value="2gis">2ГИС</option>
|
||
<option value="yandex_maps">Яндекс Карты</option>
|
||
</select>
|
||
<button
|
||
onClick={handleFetchReviews}
|
||
disabled={isFetching || !fetchSource}
|
||
className="px-5 py-3 bg-indigo-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg active:scale-95 disabled:bg-indigo-400 disabled:cursor-not-allowed transition-all"
|
||
>
|
||
{isFetching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||
{isFetching ? 'Загрузка...' : 'Загрузить отзывы'}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="px-5 py-3 bg-primary-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg active:scale-95 transition-all"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Добавить отзыв
|
||
</button>
|
||
<button
|
||
onClick={handleAnalyze}
|
||
disabled={isLoading || reviews.length === 0}
|
||
className="px-6 py-3 bg-slate-900 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg active:scale-95 disabled:bg-slate-400 transition-all"
|
||
>
|
||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||
{isLoading ? 'Анализ...' : 'Анализ отзывов'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Summary Card */}
|
||
{aiResult && !isLoading && (
|
||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 text-white p-6 rounded-[2rem] shadow-xl animate-fade-in">
|
||
<h4 className="font-bold flex items-center gap-2 mb-4"><Bot className="w-5 h-5 text-primary-400" /> Сводный отчет</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-[11px] font-medium leading-relaxed">
|
||
<div>
|
||
<h5 className="font-black text-emerald-400 uppercase tracking-widest mb-3">Главные плюсы:</h5>
|
||
<ul className="space-y-2">
|
||
{aiResult.summary.positive.map((item, i) => <li key={i} className="flex gap-2"><span className="text-emerald-500">•</span> {item}</li>)}
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h5 className="font-black text-red-400 uppercase tracking-widest mb-3">Болевые точки:</h5>
|
||
<ul className="space-y-2">
|
||
{aiResult.summary.negative.map((item, i) => <li key={i} className="flex gap-2"><span className="text-red-500">•</span> {item}</li>)}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Review Form */}
|
||
{showCreateForm && (
|
||
<ReviewCreateForm
|
||
onClose={() => setShowCreateForm(false)}
|
||
onSuccess={() => {
|
||
setShowCreateForm(false);
|
||
loadReviews();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Feedback List */}
|
||
{isLoadingReviews ? (
|
||
<div className="flex justify-center items-center py-12">
|
||
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
|
||
</div>
|
||
) : reviewsServiceUnavailable ? (
|
||
<div className="text-center py-12 text-amber-600 bg-amber-50 rounded-2xl border border-amber-200">
|
||
<p className="font-medium">Сервис отзывов временно недоступен</p>
|
||
<p className="text-sm text-slate-500 mt-1">Проверьте подключение к n8n или повторите позже</p>
|
||
<button
|
||
onClick={() => loadReviews()}
|
||
className="mt-4 px-4 py-2 bg-amber-600 text-white rounded-xl text-xs font-black uppercase tracking-wider"
|
||
>
|
||
Повторить
|
||
</button>
|
||
</div>
|
||
) : analyzedFeedback.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-400">
|
||
<p>Отзывы не найдены</p>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-wider"
|
||
>
|
||
Добавить первый отзыв
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{analyzedFeedback.map(fb => (
|
||
<FeedbackCard
|
||
key={fb.id}
|
||
feedback={fb}
|
||
onStatusChange={handleStatusChange}
|
||
onCreateIncident={handleCreateIncident}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Функция для получения читаемого названия источника
|
||
const getSourceName = (source: string) => {
|
||
const sourceNames: Record<string, string> = {
|
||
'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<FeedbackCardProps> = ({ feedback, onStatusChange, onCreateIncident }) => {
|
||
const [incident, setIncident] = useState<any>(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<any[]>(`/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 (
|
||
<div className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:border-primary-300 transition-colors group">
|
||
<div className="flex gap-4">
|
||
<div className={`w-12 h-12 rounded-2xl flex-shrink-0 flex items-center justify-center ${config.bg}`}>
|
||
<Icon className={`w-6 h-6 ${config.color}`} />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between gap-2 flex-wrap mb-2">
|
||
<div>
|
||
<h5 className="font-black text-slate-800 text-sm">{feedback.address}</h5>
|
||
<p className="text-[10px] text-slate-400 font-bold mt-0.5 uppercase tracking-tighter">
|
||
{feedback.date} • {getSourceName(feedback.source)}
|
||
{authorName && ` • ${authorName}`}
|
||
{status === 'processed' && ' • Обработан'}
|
||
{status === 'archived' && ' • В архиве'}
|
||
{processedAt && ` • ${new Date(processedAt).toLocaleDateString('ru-RU')}`}
|
||
{processedBy && ` • ${processedBy}`}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[9px] font-black text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full uppercase tracking-tighter border border-primary-100">
|
||
{feedback.category}
|
||
</span>
|
||
<span className="text-xs font-black text-amber-600">
|
||
{feedback.rating}/10
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-sm text-slate-600 leading-relaxed font-medium mb-3">«{feedback.text}»</p>
|
||
|
||
{/* Индикатор инцидента */}
|
||
{incident && (
|
||
<div className="mb-3 p-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||
<p className="text-xs text-amber-800 font-bold flex items-center gap-2">
|
||
<AlertCircle className="w-3 h-3" />
|
||
Инцидент создан: {incident.title}
|
||
<a
|
||
href={`#negative`}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
// TODO: Переход к инциденту
|
||
}}
|
||
className="text-amber-600 hover:underline ml-2"
|
||
>
|
||
Открыть
|
||
</a>
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Ссылка на оригинал */}
|
||
{sourceUrl && (
|
||
<div className="mb-3">
|
||
<a
|
||
href={sourceUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-xs text-primary-600 hover:text-primary-700 font-bold flex items-center gap-1"
|
||
>
|
||
<ExternalLink className="w-3 h-3" />
|
||
Открыть оригинал отзыва
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{/* Действия */}
|
||
<div className="flex gap-2 flex-wrap">
|
||
{status === 'new' && (
|
||
<>
|
||
<button
|
||
onClick={() => onStatusChange(Number(feedback.id), 'processed')}
|
||
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-black uppercase tracking-wider hover:bg-emerald-100 transition-all flex items-center gap-1"
|
||
>
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
Обработано
|
||
</button>
|
||
{isNegative && (
|
||
<button
|
||
onClick={() => onCreateIncident(Number(feedback.id))}
|
||
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-xl text-[10px] font-black uppercase tracking-wider hover:bg-red-100 transition-all flex items-center gap-1"
|
||
>
|
||
<AlertCircle className="w-3 h-3" />
|
||
Создать инцидент
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
{status === 'processed' && (
|
||
<button
|
||
onClick={() => onStatusChange(Number(feedback.id), 'archived')}
|
||
className="px-3 py-1.5 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-black uppercase tracking-wider hover:bg-slate-200 transition-all flex items-center gap-1"
|
||
>
|
||
<Archive className="w-3 h-3" />
|
||
В архив
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface ReviewCreateFormProps {
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
const ReviewCreateForm: React.FC<ReviewCreateFormProps> = ({ 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<Building[]>([]);
|
||
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 (
|
||
<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-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<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>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
||
Дом
|
||
</label>
|
||
{isLoadingBuildings ? (
|
||
<div className="w-full p-3 border border-slate-200 rounded-xl text-sm text-slate-400">
|
||
Загрузка домов...
|
||
</div>
|
||
) : (
|
||
<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
|
||
>
|
||
<option value="">Выберите дом</option>
|
||
{buildings.map(building => (
|
||
<option key={building.id} value={building.id}>
|
||
{building.passport?.address || building.id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
||
Источник
|
||
</label>
|
||
<select
|
||
value={formData.source}
|
||
onChange={e => setFormData({ ...formData, source: e.target.value as Review['source'] })}
|
||
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
||
>
|
||
<option value="internal">Внутренний</option>
|
||
<option value="yandex_maps">Яндекс Карты</option>
|
||
<option value="2gis">2ГИС</option>
|
||
<option value="other">Другое</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
||
Ссылка на отзыв (опционально)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.source_url}
|
||
onChange={e => 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"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
||
Автор (опционально)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.author_name}
|
||
onChange={e => setFormData({ ...formData, author_name: e.target.value })}
|
||
placeholder="Имя автора отзыва"
|
||
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
||
Текст отзыва
|
||
</label>
|
||
<textarea
|
||
value={formData.text}
|
||
onChange={e => setFormData({ ...formData, text: e.target.value })}
|
||
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
|
||
rows={4}
|
||
placeholder="Текст отзыва..."
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
|
||
Рейтинг (1-10)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={formData.rating}
|
||
onChange={e => setFormData({ ...formData, rating: parseInt(e.target.value) || 5 })}
|
||
min="1"
|
||
max="10"
|
||
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.date}
|
||
onChange={e => setFormData({ ...formData, date: e.target.value })}
|
||
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
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 ? 'Создание...' : 'Создать отзыв'}
|
||
</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>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|