Files
mkd/components/development/DevSummary.tsx
2026-02-04 00:17:04 +05:00

233 lines
12 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 { 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>
);