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

813 lines
35 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, 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>
);
};