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

458 lines
22 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import {
ClipboardList,
Eye,
Key,
Users,
Building2,
Copy,
CheckCircle2,
Play,
Pause,
X
} from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { NPSSurvey, NPSSurveyStats, Building } from '../../types';
export const NPSSurveysManager: React.FC = () => {
const [surveys, setSurveys] = useState<NPSSurvey[]>([]);
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<NPSSurvey | null>(null);
const [stats, setStats] = useState<Record<number, NPSSurveyStats>>({});
const [filterBuilding, setFilterBuilding] = useState<string>('');
const [filterStatus, setFilterStatus] = useState<string>('');
const [selectedMonth, setSelectedMonth] = useState<{ month: number; year: number }>(() => {
const now = new Date();
return { month: now.getMonth() + 1, year: now.getFullYear() };
});
useEffect(() => {
loadSurveys();
loadBuildings();
}, [filterBuilding, filterStatus]);
useEffect(() => {
// Перезагружаем статистику при изменении месяца
surveys.forEach(survey => {
loadStats(survey.id, selectedMonth.month, selectedMonth.year);
});
}, [selectedMonth]);
const loadSurveys = async () => {
try {
setIsLoading(true);
const params: any = {};
if (filterBuilding) params.building_id = filterBuilding;
if (filterStatus) params.status = filterStatus;
const queryString = new URLSearchParams(params).toString();
const data = await apiClient.get(`/pr/nps-surveys${queryString ? `?${queryString}` : ''}`);
setSurveys(data);
// Загружаем статистику для всех опросов с учетом выбранного месяца
data.forEach((survey: NPSSurvey) => {
loadStats(survey.id, selectedMonth.month, selectedMonth.year);
});
} catch (err) {
console.error('Error loading surveys:', err);
} finally {
setIsLoading(false);
}
};
const loadBuildings = async () => {
try {
const data = await apiClient.get('/buildings');
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
}
};
const loadStats = async (surveyId: number, month?: number, year?: number) => {
try {
const params = new URLSearchParams();
if (month) params.append('month', month.toString());
if (year) params.append('year', year.toString());
const queryString = params.toString();
const data = await apiClient.get(`/pr/nps-surveys/${surveyId}/stats${queryString ? `?${queryString}` : ''}`);
setStats(prev => ({ ...prev, [surveyId]: data }));
} catch (err) {
console.error('Error loading stats:', err);
}
};
const handleUpdateStatus = async (survey: NPSSurvey, newStatus: 'draft' | 'active' | 'closed') => {
try {
const updated = await apiClient.put(`/pr/nps-surveys/${survey.id}`, { status: newStatus });
setSurveys(surveys.map(s => s.id === survey.id ? updated : s));
} catch (err: any) {
alert(`Ошибка обновления статуса: ${err.message || 'Неизвестная ошибка'}`);
}
};
const getSurveyLink = (survey: NPSSurvey, apartment?: string) => {
const baseUrl = `${window.location.origin}/nps/${survey.id}?key=${survey.accessKey}`;
if (apartment) {
return `${baseUrl}&apartment=${encodeURIComponent(apartment)}`;
}
return baseUrl;
};
const [copiedSurveyId, setCopiedSurveyId] = useState<number | null>(null);
const [showApartmentInput, setShowApartmentInput] = useState<number | null>(null);
const [apartmentForLink, setApartmentForLink] = useState<string>('');
const handleCopyLink = (survey: NPSSurvey, apartment?: string) => {
const link = getSurveyLink(survey, apartment);
navigator.clipboard.writeText(link);
setCopiedSurveyId(survey.id);
setTimeout(() => setCopiedSurveyId(null), 2000);
};
const getNPSColor = (nps: number) => {
if (nps >= 50) return 'text-emerald-600';
if (nps >= 0) return 'text-amber-600';
return 'text-red-600';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'bg-emerald-100 text-emerald-700';
case 'closed': return 'bg-slate-100 text-slate-700';
default: return 'bg-amber-100 text-amber-700';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'active': return 'Активен';
case 'closed': return 'Закрыт';
default: return 'Черновик';
}
};
return (
<div className="space-y-6">
{/* Заголовок и фильтры */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h3 className="text-lg font-black text-slate-800">Опросы NPS</h3>
<p className="text-xs text-slate-500 mt-1">Опросы создаются автоматически для каждого дома</p>
</div>
<div className="flex gap-2 flex-wrap">
{/* Выбор месяца для статистики */}
<div className="flex gap-2 items-center">
<select
value={selectedMonth.month}
onChange={(e) => setSelectedMonth({ ...selectedMonth, month: parseInt(e.target.value) })}
className="px-3 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{[1,2,3,4,5,6,7,8,9,10,11,12].map(m => (
<option key={m} value={m}>
{new Date(2000, m - 1).toLocaleDateString('ru-RU', { month: 'long' })}
</option>
))}
</select>
<select
value={selectedMonth.year}
onChange={(e) => setSelectedMonth({ ...selectedMonth, year: parseInt(e.target.value) })}
className="px-3 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<select
value={filterBuilding}
onChange={(e) => setFilterBuilding(e.target.value)}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все дома</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{b.passport?.address || b.id}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все статусы</option>
<option value="draft">Черновик</option>
<option value="active">Активен</option>
<option value="closed">Закрыт</option>
</select>
</div>
</div>
{/* Список опросов */}
{isLoading ? (
<div className="text-center py-12">
<div className="w-12 h-12 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка опросов...</p>
</div>
) : surveys.length === 0 ? (
<div className="text-center py-12 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200">
<ClipboardList className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 font-bold">Нет опросов</p>
<p className="text-sm text-slate-500 mt-1">Опросы создаются автоматически для каждого дома 1 числа каждого месяца</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{surveys.map(survey => {
const surveyStats = stats[survey.id];
return (
<div
key={survey.id}
className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200 hover:shadow-xl transition-shadow"
>
{/* Заголовок */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h4 className="text-lg font-black text-slate-800 mb-1">{survey.title}</h4>
{survey.address && (
<p className="text-sm text-slate-600 flex items-center gap-1">
<Building2 className="w-4 h-4" />
{survey.address}
</p>
)}
</div>
<span className={`px-3 py-1 rounded-lg text-xs font-bold ${getStatusColor(survey.status)}`}>
{getStatusLabel(survey.status)}
</span>
</div>
{/* Статистика */}
{surveyStats && surveyStats.totalResponses > 0 ? (
<div className="mb-4 p-4 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl border border-indigo-100">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-1">NPS</p>
<p className={`text-3xl font-black ${getNPSColor(surveyStats.nps)}`}>
{surveyStats.nps > 0 ? '+' : ''}{surveyStats.nps}
</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-600 mb-1">Средняя оценка</p>
<p className="text-2xl font-black text-slate-800">{surveyStats.avgScore.toFixed(1)}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center pt-3 border-t border-indigo-200">
<div>
<p className="text-xs text-slate-600 mb-1">Промоутеры</p>
<p className="text-sm font-black text-emerald-600">{surveyStats.promoters}</p>
</div>
<div>
<p className="text-xs text-slate-600 mb-1">Нейтральные</p>
<p className="text-sm font-black text-amber-600">{surveyStats.passives}</p>
</div>
<div>
<p className="text-xs text-slate-600 mb-1">Критики</p>
<p className="text-sm font-black text-red-600">{surveyStats.detractors}</p>
</div>
</div>
<p className="text-xs text-slate-500 mt-3 text-center">
Ответов за {new Date(selectedMonth.year, selectedMonth.month - 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}: <span className="font-black">{surveyStats.totalResponses}</span>
</p>
</div>
) : (
<div className="mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 text-center">
<p className="text-sm text-slate-500">
Нет ответов за {new Date(selectedMonth.year, selectedMonth.month - 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}
</p>
</div>
)}
{/* Действия */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setSelectedSurvey(survey);
setShowAccessKeyModal(true);
}}
className="px-3 py-2 bg-emerald-100 hover:bg-emerald-200 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Получить ссылку для жителей"
>
<Key className="w-4 h-4" />
Ссылка
</button>
<button
onClick={() => {
setShowApartmentInput(survey.id);
setApartmentForLink('');
}}
className="px-3 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Ссылка для чата с квартирой"
>
<Users className="w-4 h-4" />
Для чата
</button>
{survey.status === 'draft' && (
<button
onClick={() => handleUpdateStatus(survey, 'active')}
className="px-3 py-2 bg-emerald-100 hover:bg-emerald-200 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Активировать"
>
<Play className="w-4 h-4" />
</button>
)}
{survey.status === 'active' && (
<button
onClick={() => handleUpdateStatus(survey, 'closed')}
className="px-3 py-2 bg-amber-100 hover:bg-amber-200 text-amber-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Закрыть"
>
<Pause className="w-4 h-4" />
</button>
)}
{/* Модальное окно для ввода номера квартиры */}
{showApartmentInput === survey.id && (
<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-md w-full shadow-2xl">
<h3 className="text-lg font-black text-slate-800 mb-4">Ссылка для отправки в чат</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Номер квартиры
</label>
<input
type="text"
value={apartmentForLink}
onChange={(e) => setApartmentForLink(e.target.value)}
placeholder="Например: 45"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowApartmentInput(null);
setApartmentForLink('');
}}
className="flex-1 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
Отмена
</button>
<button
onClick={() => {
if (apartmentForLink.trim()) {
handleCopyLink(survey, apartmentForLink.trim());
setShowApartmentInput(null);
setApartmentForLink('');
}
}}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Копировать ссылку
</button>
</div>
{apartmentForLink.trim() && (
<div className="mt-4 p-3 bg-indigo-50 rounded-xl border border-indigo-200">
<p className="text-xs text-indigo-700 mb-2 font-bold">Ссылка будет содержать:</p>
<p className="text-xs text-indigo-800 font-mono break-all">
{getSurveyLink(survey, apartmentForLink.trim())}
</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && selectedSurvey && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-8 max-w-md w-full shadow-2xl">
<h3 className="text-xl font-black text-slate-800 mb-6">Ссылка на опрос</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">
Ключ доступа
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={selectedSurvey.accessKey}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono"
/>
<button
onClick={() => {
navigator.clipboard.writeText(selectedSurvey.accessKey);
alert('Ключ скопирован');
}}
className="px-4 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">
Ссылка на опрос
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={getSurveyLink(selectedSurvey)}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm"
/>
<button
onClick={() => handleCopyLink(selectedSurvey)}
className="px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copiedSurveyId === selectedSurvey.id ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="mt-4 p-4 bg-emerald-50 rounded-xl border border-emerald-200">
<p className="text-xs font-bold text-emerald-800 mb-2">💬 Для отправки в чат:</p>
<p className="text-xs text-emerald-700 mb-3">
Добавьте параметр <code className="bg-emerald-100 px-1 rounded">?apartment=XX</code> к ссылке,
чтобы автоматически указать номер квартиры жителя.
</p>
<p className="text-xs text-emerald-600">
Пример: <code className="bg-emerald-100 px-1 rounded">
{getSurveyLink(selectedSurvey)}&apartment=45
</code>
</p>
</div>
<div className="mt-6 p-4 bg-amber-50 rounded-xl border border-amber-200">
<p className="text-xs text-amber-800">
<strong className="font-black">Важно:</strong> Ссылка содержит ключ доступа. Отправляйте её только жителям выбранного дома.
</p>
</div>
</div>
<button
onClick={() => {
setShowAccessKeyModal(false);
setSelectedSurvey(null);
}}
className="w-full mt-6 px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Закрыть
</button>
</div>
</div>
)}
</div>
);
};