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

404 lines
19 KiB
TypeScript
Executable File
Raw Permalink 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 { ArrowLeft, BarChart3, Users, TrendingUp, MessageSquare, Calendar, Building2, Eye, Key, Copy, CheckCircle2, X } from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { NPSSurvey, NPSSurveyStats, NPSResponse } from '../../types';
interface NPSSurveyStatsPageProps {
surveyId: string | number;
onBack?: () => void;
}
export const NPSSurveyStatsPage: React.FC<NPSSurveyStatsPageProps> = ({ surveyId, onBack }) => {
const [survey, setSurvey] = useState<NPSSurvey | null>(null);
const [stats, setStats] = useState<NPSSurveyStats | null>(null);
const [responses, setResponses] = useState<NPSResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
const [copied, setCopied] = useState(false);
const [selectedMonth, setSelectedMonth] = useState<{ month: number; year: number }>(() => {
const now = new Date();
return { month: now.getMonth() + 1, year: now.getFullYear() };
});
useEffect(() => {
loadData();
}, [surveyId, selectedMonth]);
const loadData = async () => {
try {
setIsLoading(true);
const params = new URLSearchParams();
params.append('month', selectedMonth.month.toString());
params.append('year', selectedMonth.year.toString());
const [surveyData, statsData, responsesData] = await Promise.all([
apiClient.get<NPSSurvey>(`/pr/nps-surveys/${surveyId}`),
apiClient.get<NPSSurveyStats>(`/pr/nps-surveys/${surveyId}/stats?${params.toString()}`),
apiClient.get<NPSResponse[]>(`/pr/nps-surveys/${surveyId}/responses?${params.toString()}`)
]);
setSurvey(surveyData);
setStats(statsData);
setResponses(responsesData);
} catch (err) {
console.error('Error loading survey data:', err);
setSurvey(null);
setStats(null);
setResponses([]);
} finally {
setIsLoading(false);
}
};
const getSurveyLink = () => {
if (!survey) return '';
return `${window.location.origin}/nps/${survey.id}?key=${survey.accessKey}`;
};
const handleCopyLink = () => {
const link = getSurveyLink();
navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const getNPSColor = (nps: number) => {
if (nps >= 50) return 'text-emerald-600';
if (nps >= 0) return 'text-amber-600';
return 'text-red-600';
};
const getScoreColor = (score: number) => {
if (score >= 9) return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (score >= 7) return 'bg-amber-100 text-amber-700 border-amber-200';
return 'bg-red-100 text-red-700 border-red-200';
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка статистики...</p>
</div>
</div>
);
}
if (!survey) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="text-center text-slate-600">
<p className="font-bold mb-2">Не удалось загрузить данные опроса NPS</p>
{onBack && (
<button
onClick={onBack}
className="mt-2 px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-bold"
>
Вернуться к списку опросов
</button>
)}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Header */}
{onBack && (
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors group"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span className="text-sm font-bold">Вернуться к списку опросов</span>
</button>
<div className="flex items-center gap-3">
<button
onClick={() => setShowAccessKeyModal(true)}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 transition-colors"
>
<Key className="w-4 h-4" />
Получить ссылку
</button>
</div>
</div>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Заголовок */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className="p-4 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-3xl text-white shadow-xl">
<BarChart3 className="w-8 h-8" />
</div>
<div>
<h1 className="text-3xl font-black text-slate-800">{survey.title}</h1>
{survey.address && (
<p className="text-sm text-slate-600 flex items-center gap-1 mt-1">
<Building2 className="w-4 h-4" />
{survey.address}
</p>
)}
</div>
</div>
{/* Выбор месяца */}
<div className="flex gap-2 items-center">
<select
value={selectedMonth.month}
onChange={(e) => setSelectedMonth({ ...selectedMonth, month: parseInt(e.target.value) })}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-bold focus:outline-none focus:ring-2 focus:ring-indigo-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-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-bold focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
</div>
{/* Основная статистика */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* NPS Score */}
<div className="lg:col-span-2 bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<h3 className="text-lg font-black text-slate-800 mb-6">Индекс NPS</h3>
<div className="text-center">
<div className={`inline-flex items-center justify-center w-40 h-40 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white mb-6 shadow-xl ${getNPSColor(stats.nps)}`}>
<div className="text-center">
<div className="text-6xl font-black">{stats.nps > 0 ? '+' : ''}{stats.nps}</div>
<div className="text-sm font-bold opacity-90">NPS</div>
</div>
</div>
<p className="text-lg font-bold text-slate-800 mb-2">
{stats.nps >= 50 ? 'Отличный показатель'
: stats.nps >= 0 ? 'Хороший показатель'
: 'Требует внимания'}
</p>
<p className="text-sm text-slate-600">
Средняя оценка:{' '}
<span className="font-black">
{(stats.avgScore ?? 0).toFixed(1)}
</span>{' '}
из 10
</p>
</div>
</div>
{/* Общая статистика */}
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<h3 className="text-lg font-black text-slate-800 mb-6">Общая статистика</h3>
<div className="space-y-4">
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Всего ответов</p>
<p className="text-3xl font-black text-slate-800">{stats.totalResponses ?? 0}</p>
</div>
<div className="pt-4 border-t border-slate-200">
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">Распределение</p>
<div className="space-y-3">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-emerald-700">Промоутеры</span>
<span className="text-sm font-black text-slate-800">
{stats.promoters ?? 0} ({(stats.promoterPercent ?? 0).toFixed(1)}%)
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full transition-all duration-500" style={{ width: `${stats.promoterPercent}%` }}></div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-amber-700">Нейтральные</span>
<span className="text-sm font-black text-slate-800">
{stats.passives ?? 0} (
{stats.totalResponses
? ((stats.passives / stats.totalResponses) * 100).toFixed(1)
: '0.0'
}%)
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-500"
style={{
width: `${stats.totalResponses ? (stats.passives / stats.totalResponses) * 100 : 0}%`
}}
></div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-red-700">Критики</span>
<span className="text-sm font-black text-slate-800">
{stats.detractors ?? 0} ({(stats.detractorPercent ?? 0).toFixed(1)}%)
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div className="h-full bg-red-500 rounded-full transition-all duration-500" style={{ width: `${stats.detractorPercent}%` }}></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Список ответов */}
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-black text-slate-800">
Ответы жителей за {new Date(selectedMonth.year, selectedMonth.month - 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}
</h3>
<span className="text-sm text-slate-500">Всего: {responses.length}</span>
</div>
{responses.length === 0 ? (
<div className="text-center py-12">
<MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-600">Пока нет ответов</p>
</div>
) : (
<div className="space-y-4">
{responses.map(response => (
<div
key={response.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-200 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className={`px-3 py-1 rounded-lg border-2 font-black text-lg ${getScoreColor(response.score)}`}>
{response.score}
</div>
<div>
{response.respondentName && (
<p className="font-bold text-slate-800">{response.respondentName}</p>
)}
{response.apartment && (
<p className="text-xs text-slate-500">Квартира {response.apartment}</p>
)}
</div>
</div>
{response.comment && (
<p className="text-sm text-slate-700 mt-2 pl-12">{response.comment}</p>
)}
</div>
<div className="text-right">
<p className="text-xs text-slate-500">
{new Date(response.createdAt).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && survey && (
<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-lg w-full shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-xl">
<Key className="w-6 h-6 text-indigo-600" />
</div>
<h3 className="text-xl font-black text-slate-800">Ссылка на опрос</h3>
</div>
<button
onClick={() => setShowAccessKeyModal(false)}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ключ доступа
</label>
<div className="flex gap-2">
<input
type="text"
value={survey.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(survey.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-black text-slate-600 uppercase tracking-wider mb-2">
Ссылка на опрос
</label>
<div className="flex gap-2">
<input
type="text"
value={getSurveyLink()}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm"
/>
<button
onClick={handleCopyLink}
className="px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copied ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</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)}
className="w-full mt-6 px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Закрыть
</button>
</div>
</div>
)}
</div>
);
};