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