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

201 lines
13 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 { Smile, Frown, Meh, TrendingUp, TrendingDown, Target, Heart, Share2, Send, MessageSquare, Hash, PartyPopper, Calendar } from 'lucide-react';
import { MOCK_BUILDINGS } from '../../constants';
import { backendApi } from '../../services/apiClient';
import type { SMMChannel, PREvent } from '../../types';
const CHANNEL_ICONS: Record<string, typeof Send> = { tg: Send, vk: Share2, wa: MessageSquare, other: Hash };
interface Props {
onNavigate: (tab: any) => void;
}
export const PRSummary: React.FC<Props> = ({ onNavigate }) => {
const avgNPS = 68;
const sentiment = { positive: 72, neutral: 18, negative: 10 };
const [smmChannels, setSmmChannels] = useState<SMMChannel[]>([]);
const [smmLoading, setSmmLoading] = useState(true);
const [events, setEvents] = useState<PREvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(true);
useEffect(() => {
setSmmLoading(true);
backendApi.getSMMChannels()
.then((list) => setSmmChannels(Array.isArray(list) ? list : []))
.catch(() => setSmmChannels([]))
.finally(() => setSmmLoading(false));
}, []);
useEffect(() => {
setEventsLoading(true);
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const to = new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString().slice(0, 10);
backendApi.getPREvents({ from, to, limit: 30 })
.then((list) => setEvents(Array.isArray(list) ? list : []))
.catch(() => setEvents([]))
.finally(() => setEventsLoading(false));
}, []);
return (
<div className="space-y-6 animate-fade-in">
{/* NPS Card */}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<Heart className="absolute -top-4 -right-4 w-48 h-48 opacity-10 rotate-12 text-primary-400" />
<div className="relative z-10">
<div className="flex justify-between items-start mb-12">
<div>
<h3 className="text-3xl font-black mb-2">Net Promoter Score</h3>
<p className="text-slate-400 text-sm font-medium">Общий индекс лояльности по компании</p>
</div>
<div className="text-right">
<p className="text-6xl font-black text-emerald-400">+{avgNPS}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mt-1">Тенденция: Рост</p>
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<SentimentWidget icon={Smile} label="Промоутеры" value={sentiment.positive} color="text-emerald-400" />
<SentimentWidget icon={Meh} label="Нейтралы" value={sentiment.neutral} color="text-slate-400" />
<SentimentWidget icon={Frown} label="Критики" value={sentiment.negative} color="text-red-400" />
</div>
</div>
</div>
{/* Performance Lists */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-emerald-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<TrendingUp className="w-4 h-4"/> Топ лояльных домов
</h4>
<div className="space-y-3">
{MOCK_BUILDINGS.filter(b => b.nps > 30).slice(0, 3).map(b => (
<div key={b.id} className="flex items-center justify-between p-3 bg-emerald-50/50 rounded-2xl border border-emerald-100">
<span className="text-xs font-bold text-slate-700 truncate mr-2">{b.passport.address}</span>
<span className="px-2 py-0.5 rounded-lg text-[10px] font-black bg-emerald-100 text-emerald-600">+{b.nps} NPS</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-red-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<TrendingDown className="w-4 h-4"/> Зоны внимания (Критики)
</h4>
<div className="space-y-3">
{MOCK_BUILDINGS.filter(b => b.nps < 10).slice(0, 3).map(b => (
<div key={b.id} className="flex items-center justify-between p-3 bg-red-50/50 rounded-2xl border border-red-100">
<span className="text-xs font-bold text-slate-700 truncate mr-2">{b.passport.address}</span>
<span className="px-2 py-0.5 rounded-lg text-[10px] font-black bg-red-100 text-red-600">{b.nps} NPS</span>
</div>
))}
</div>
</div>
</div>
{/* SMM: соцсети и подписчики */}
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<Share2 className="w-4 h-4 text-primary-500"/> SMM соцсети и подписчики
</h4>
{smmLoading ? (
<p className="text-sm text-slate-400">Загрузка...</p>
) : smmChannels.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-slate-500 mb-2">Каналы не добавлены</p>
<button type="button" onClick={() => onNavigate('smm')} className="text-primary-600 text-xs font-bold uppercase hover:underline">Добавить каналы в SMM</button>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{smmChannels.map(ch => {
const Icon = CHANNEL_ICONS[ch.type] || Hash;
const count = ch.subscribersCount ?? ch.lastSnapshot?.subscribersCount;
const style = ch.type === 'tg' ? 'text-sky-500 bg-sky-50' : ch.type === 'vk' ? 'text-blue-600 bg-blue-50' : ch.type === 'wa' ? 'text-emerald-500 bg-emerald-50' : 'text-slate-600 bg-slate-50';
return (
<div key={ch.id} className="flex items-center gap-4 p-4 rounded-2xl border border-slate-100 bg-slate-50/50">
<div className={`w-12 h-12 rounded-xl ${style} flex items-center justify-center shrink-0`}>
<Icon className="w-6 h-6"/>
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 truncate">{ch.name}</p>
<p className="text-[11px] font-black text-slate-600">{count != null ? `${count.toLocaleString('ru-RU')} подписчиков` : '—'}</p>
</div>
</div>
);
})}
</div>
<p className="text-[10px] text-slate-400 font-bold mt-4 px-1">
Всего: <span className="text-slate-700 font-black">{smmChannels.reduce((s, c) => s + (c.subscribersCount ?? c.lastSnapshot?.subscribersCount ?? 0), 0).toLocaleString('ru-RU')}</span> подписчиков
</p>
</>
)}
</div>
{/* Мероприятия */}
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<PartyPopper className="w-4 h-4 text-primary-500"/> Ближайшие мероприятия
</h4>
{eventsLoading ? (
<p className="text-sm text-slate-400">Загрузка...</p>
) : events.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-slate-500 mb-2">Нет запланированных мероприятий</p>
<button type="button" onClick={() => onNavigate('events')} className="text-primary-600 text-xs font-bold uppercase hover:underline">Перейти к реестру</button>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-4">
<div className="p-3 bg-primary-50 rounded-xl border border-primary-100">
<p className="text-[10px] font-bold uppercase text-primary-600">Запланировано</p>
<p className="text-xl font-black text-primary-800">{events.filter(e => e.status === 'planned' || e.status === 'in_progress').length}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl border border-emerald-100">
<p className="text-[10px] font-bold uppercase text-emerald-600">Проведено</p>
<p className="text-xl font-black text-emerald-800">{events.filter(e => e.status === 'completed').length}</p>
</div>
</div>
<div className="space-y-2">
{events.filter(e => e.status === 'planned' || e.status === 'in_progress').slice(0, 5).map((ev) => (
<div key={String(ev.id)} className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-slate-800 truncate">{ev.title}</p>
<p className="text-[10px] text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3"/> {typeof ev.date === 'string' ? ev.date : String(ev.date)}
</p>
</div>
<button type="button" onClick={() => onNavigate('events')} className="text-primary-600 text-[10px] font-bold uppercase shrink-0">Открыть</button>
</div>
))}
</div>
<button type="button" onClick={() => onNavigate('events')} className="w-full mt-4 py-2 text-primary-600 text-xs font-bold uppercase hover:underline">
Перейти к реестру мероприятий
</button>
</>
)}
</div>
<button onClick={() => onNavigate('negative')} className="w-full py-4 bg-slate-900 text-white rounded-3xl font-black text-xs uppercase tracking-widest flex items-center justify-center gap-3 shadow-xl active:scale-95 transition-all">
<Target className="w-5 h-5 text-red-400"/> Отработать 2 критических отзыва
</button>
</div>
);
};
const SentimentWidget = ({ icon: Icon, label, value, color }: any) => (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon className={`w-4 h-4 ${color}`}/>
<span className="text-[10px] font-black uppercase text-slate-500">{label}</span>
</div>
<div className="flex items-end gap-2">
<p className="text-2xl font-black leading-none">{value}%</p>
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden mb-1">
<div className={`h-full ${color.replace('text-', 'bg-')} rounded-full`} style={{ width: `${value}%` }} />
</div>
</div>
</div>
);