Files
mkd/components/pr/PRFeedbackFeed.tsx

813 lines
35 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};