201 lines
13 KiB
TypeScript
Executable File
201 lines
13 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|