Files
mkd/components/development/DevSummary.tsx

233 lines
12 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { TrendingUp, Vote, Users, Briefcase, Map as MapIcon, Target, ChevronRight } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
interface Props {
onNavigate: (tab: any) => void;
}
const CACHE_KEY = 'mkd_dev_summary_cache';
const CACHE_DEFAULT = { summary: { growthM2: 0, ossSuccessRate: 0, cac: 15000, potentialRevenue: 0 }, locations: [] };
export const DevSummary: React.FC<Props> = ({ onNavigate }) => {
const cached = readCache<{ summary: any; locations: any[] }>(CACHE_KEY, CACHE_DEFAULT);
const [summary, setSummary] = useState(cached.summary || CACHE_DEFAULT.summary);
const [locations, setLocations] = useState<any[]>(cached.locations || []);
const hasCache = (cached.locations?.length ?? 0) > 0;
const [loading, setLoading] = useState(!hasCache);
useEffect(() => {
const fetchData = async () => {
if (!hasCache) setLoading(true);
try {
const [summaryData, locationsData] = await Promise.all([
backendApi.getDevelopmentSummary(),
backendApi.getDevelopmentLocations()
]);
setSummary(summaryData);
setLocations(locationsData);
saveCache(CACHE_KEY, { summary: summaryData, locations: locationsData });
} catch (error) {
console.error('Error fetching development summary:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const onRefresh = () => {
(async () => {
try {
const [summaryData, locationsData] = await Promise.all([
backendApi.getDevelopmentSummary(),
backendApi.getDevelopmentLocations()
]);
setSummary(summaryData);
setLocations(locationsData);
saveCache(CACHE_KEY, { summary: summaryData, locations: locationsData });
} catch (e) {
console.error('Error fetching development summary:', e);
}
})();
};
window.addEventListener(REFRESH_EVENTS.devSummary, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.devSummary, onRefresh);
}, []);
useEffect(() => {
const interval = setInterval(async () => {
try {
const [summaryData, locationsData] = await Promise.all([
backendApi.getDevelopmentSummary(),
backendApi.getDevelopmentLocations()
]);
setSummary(summaryData);
setLocations(locationsData);
saveCache(CACHE_KEY, { summary: summaryData, locations: locationsData });
} catch (e) {
console.error('Error fetching development summary:', e);
}
}, 10 * 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="space-y-6 animate-fade-in">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={TrendingUp}
label="Прирост фонда"
value={loading ? '...' : `+${summary.growthM2} м²`}
color="text-emerald-600"
bg="bg-emerald-50"
/>
<StatCard
icon={Vote}
label="Успех ОСС"
value={loading ? '...' : `${summary.ossSuccessRate}%`}
color="text-blue-600"
bg="bg-blue-50"
/>
<StatCard
icon={Users}
label="CAC (За дом)"
value={loading ? '...' : `${summary.cac.toLocaleString()}`}
color="text-amber-600"
bg="bg-amber-50"
/>
<StatCard
icon={Briefcase}
label="Выручка в воронке"
value={loading ? '...' : `${(summary.potentialRevenue/1000).toFixed(0)}k ₽`}
color="text-violet-600"
bg="bg-violet-50"
/>
</div>
{/* Capture Map Widget */}
<div className="bg-slate-900 rounded-[2rem] p-8 text-white shadow-xl relative overflow-hidden min-h-[350px]">
<div className="relative z-10 max-w-sm">
<div className="flex items-center gap-2 text-primary-400 mb-2">
<MapIcon className="w-5 h-5"/>
<span className="text-[10px] font-black uppercase tracking-widest">Стратегия экспансии</span>
</div>
<h3 className="text-3xl font-black mb-4 leading-tight">Карта присутствия</h3>
<p className="text-xs text-slate-400 font-medium mb-8">Интерактивный мониторинг конкурентов и зон активного голосования в Центральном районе.</p>
</div>
{/* Visual Map Simulation */}
<div className="absolute top-10 right-10 left-10 md:left-auto md:w-1/2 bottom-10 bg-slate-800/50 rounded-2xl border border-slate-700 overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:30px_30px]"></div>
{/* Map Pointers - динамические из БД */}
{locations.length > 0 ? (
locations.slice(0, 10).map((loc, idx) => {
// Распределяем точки по карте (упрощенная логика)
const positions = [
{ top: '20%', left: '30%' },
{ top: '45%', left: '60%' },
{ top: '70%', left: '25%' },
{ top: '35%', left: '80%' },
{ top: '15%', left: '70%' },
{ top: '60%', left: '40%' },
{ top: '30%', left: '50%' },
{ top: '50%', left: '20%' },
{ top: '80%', left: '60%' },
{ top: '10%', left: '40%' },
];
const pos = positions[idx % positions.length];
const label = loc.status === 'voting' ? 'ОСС ИДЕТ' : undefined;
return (
<MapPointer
key={loc.id || idx}
top={pos.top}
left={pos.left}
status={loc.status}
label={label}
address={loc.address}
/>
);
})
) : (
// Fallback статические точки, если нет данных
<>
<MapPointer top="20%" left="30%" status="ours" />
<MapPointer top="45%" left="60%" status="ours" />
<MapPointer top="70%" left="25%" status="voting" label="ОСС ИДЕТ" />
<MapPointer top="35%" left="80%" status="competitor" />
<MapPointer top="15%" left="70%" status="competitor" />
</>
)}
<div className="absolute bottom-4 right-4 bg-slate-900/90 p-3 rounded-xl border border-slate-700 text-[9px] space-y-1.5 shadow-2xl">
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"/> Наш фонд</div>
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse"/> Проходит ОСС</div>
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-red-500"/> Другие УК</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button onClick={() => onNavigate('pipeline')} className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm flex items-center justify-between group hover:border-primary-400 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary-50 text-primary-600 rounded-2xl group-hover:bg-primary-600 group-hover:text-white transition-colors">
<Target className="w-6 h-6"/>
</div>
<div className="text-left">
<p className="font-bold text-slate-800">Перейти в воронку</p>
<p className="text-xs text-slate-500">Управление лидами</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all"/>
</button>
<button onClick={() => onNavigate('oss')} className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm flex items-center justify-between group hover:border-primary-400 transition-all">
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl group-hover:bg-amber-500 group-hover:text-white transition-colors">
<Vote className="w-6 h-6"/>
</div>
<div className="text-left">
<p className="font-bold text-slate-800">Центр ОСС</p>
<p className="text-xs text-slate-500">Контроль текущих голосований</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all"/>
</button>
</div>
</div>
);
};
const StatCard = ({ icon: Icon, label, value, color, bg }: any) => (
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-2">
<div className={`p-2 ${bg} ${color} rounded-lg`}><Icon className="w-5 h-5"/></div>
</div>
<p className="text-xl font-black text-slate-800">{value}</p>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">{label}</p>
</div>
);
const MapPointer = ({ top, left, status, label, address }: any) => (
<div className="absolute group" style={{ top, left }}>
<div className={`w-3 h-3 rounded-full relative cursor-pointer ${
status === 'ours' ? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]' :
status === 'voting' ? 'bg-amber-400 animate-pulse shadow-[0_0_15px_rgba(251,191,36,0.5)]' :
'bg-red-500'
}`}>
{(label || address) && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 bg-white text-slate-900 text-[7px] font-black px-1 rounded shadow-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
{label || address}
</div>
)}
</div>
</div>
);