393 lines
19 KiB
TypeScript
393 lines
19 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
import {
|
|||
|
|
Scale,
|
|||
|
|
FileSignature,
|
|||
|
|
Gavel,
|
|||
|
|
FileText,
|
|||
|
|
ShieldAlert,
|
|||
|
|
Calendar,
|
|||
|
|
TrendingUp,
|
|||
|
|
Loader2,
|
|||
|
|
AlertTriangle
|
|||
|
|
} from 'lucide-react';
|
|||
|
|
import { authFetch } from '../../services/apiClient';
|
|||
|
|
import { readCache, saveCache } from '../../hooks/useCachedFetch';
|
|||
|
|
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
onNavigate: (tab: any) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Contract {
|
|||
|
|
id: string;
|
|||
|
|
status: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface CourtCase {
|
|||
|
|
id: string;
|
|||
|
|
caseNumber: string;
|
|||
|
|
subject: string;
|
|||
|
|
amount: number;
|
|||
|
|
nextHearingDate?: string;
|
|||
|
|
judge?: string;
|
|||
|
|
status: string;
|
|||
|
|
recoveredAmount?: number;
|
|||
|
|
amountAtBailiffs?: number;
|
|||
|
|
fsspLastActionDate?: string;
|
|||
|
|
debtorName?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface CounterpartyCheck {
|
|||
|
|
id: number;
|
|||
|
|
riskLevel: string;
|
|||
|
|
checkedDate?: string;
|
|||
|
|
checkedAt?: string; // альтернативное поле
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const CACHE_KEY = 'mkd_legal_summary_cache';
|
|||
|
|
const CACHE_DEFAULT = { preTrialStats: { total: 0, inProgress: 0, promisedPayment: 0, transferredToCourt: 0 }, contracts: [], courtCases: [], counterpartyChecks: [], efficiency: { percentage: 0, recovered: 0, totalClaimed: 0, totalPenalty: 0 } };
|
|||
|
|
|
|||
|
|
export const LegalSummary: React.FC<Props> = ({ onNavigate }) => {
|
|||
|
|
const cached = readCache<typeof CACHE_DEFAULT>(CACHE_KEY, CACHE_DEFAULT);
|
|||
|
|
const hasCache = (cached.contracts?.length ?? 0) > 0 || (cached.courtCases?.length ?? 0) > 0;
|
|||
|
|
|
|||
|
|
const [loading, setLoading] = useState(!hasCache);
|
|||
|
|
const [preTrialStats, setPreTrialStats] = useState(cached.preTrialStats || CACHE_DEFAULT.preTrialStats);
|
|||
|
|
const [contracts, setContracts] = useState<Contract[]>(cached.contracts || []);
|
|||
|
|
const [courtCases, setCourtCases] = useState<CourtCase[]>(cached.courtCases || []);
|
|||
|
|
const [counterpartyChecks, setCounterpartyChecks] = useState<CounterpartyCheck[]>(cached.counterpartyChecks || []);
|
|||
|
|
const [efficiency, setEfficiency] = useState(cached.efficiency || CACHE_DEFAULT.efficiency);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadAllData();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const onRefresh = () => loadAllData(false);
|
|||
|
|
window.addEventListener(REFRESH_EVENTS.legal, onRefresh);
|
|||
|
|
return () => window.removeEventListener(REFRESH_EVENTS.legal, onRefresh);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const interval = setInterval(() => loadAllData(false), 10 * 1000);
|
|||
|
|
return () => clearInterval(interval);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const loadAllData = async (showSpinner = true) => {
|
|||
|
|
try {
|
|||
|
|
if (showSpinner && !hasCache) setLoading(true);
|
|||
|
|
|
|||
|
|
// Загружаем все данные параллельно
|
|||
|
|
const [preTrialRes, contractsRes, courtCasesRes, counterpartiesRes] = await Promise.all([
|
|||
|
|
authFetch('/api/legal/pre-trial-work'),
|
|||
|
|
authFetch('/api/legal/contracts?viewMode=active'),
|
|||
|
|
authFetch('/api/legal/court-cases'),
|
|||
|
|
authFetch('/api/legal/counterparties?limit=100')
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Обрабатываем досудебную работу
|
|||
|
|
if (preTrialRes.ok) {
|
|||
|
|
const preTrialData = await preTrialRes.json();
|
|||
|
|
setPreTrialStats({
|
|||
|
|
total: preTrialData.length || 0,
|
|||
|
|
inProgress: preTrialData.filter((w: any) => w.status === 'in_progress').length,
|
|||
|
|
promisedPayment: preTrialData.filter((w: any) => w.status === 'promised_payment').length,
|
|||
|
|
transferredToCourt: preTrialData.filter((w: any) => w.status === 'transferred_to_court').length
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обрабатываем договоры
|
|||
|
|
if (contractsRes.ok) {
|
|||
|
|
const contractsData = await contractsRes.json();
|
|||
|
|
setContracts(contractsData || []);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обрабатываем судебные дела
|
|||
|
|
if (courtCasesRes.ok) {
|
|||
|
|
const courtCasesData = await courtCasesRes.json();
|
|||
|
|
setCourtCases(courtCasesData || []);
|
|||
|
|
|
|||
|
|
// Рассчитываем эффективность на основе реальных данных
|
|||
|
|
const debtRecoveryCases = (courtCasesData || []).filter((c: any) =>
|
|||
|
|
c.type === 'debt_recovery' ||
|
|||
|
|
(c.subject && c.subject.toLowerCase().includes('взыскание'))
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const totalClaimed = debtRecoveryCases.reduce((sum: number, c: any) => sum + (parseFloat(c.amount) || 0), 0);
|
|||
|
|
const totalRecovered = debtRecoveryCases.reduce((sum: number, c: any) =>
|
|||
|
|
sum + (parseFloat(c.recoveredAmount) || 0) + (parseFloat(c.amountAtBailiffs) || 0), 0
|
|||
|
|
);
|
|||
|
|
const totalPenalty = debtRecoveryCases.reduce((sum: number, c: any) => sum + (parseFloat(c.penaltyAmount) || 0), 0);
|
|||
|
|
|
|||
|
|
const percentage = totalClaimed > 0
|
|||
|
|
? Math.round((totalRecovered / totalClaimed) * 100)
|
|||
|
|
: 0;
|
|||
|
|
|
|||
|
|
setEfficiency({
|
|||
|
|
percentage: Math.min(percentage, 100),
|
|||
|
|
recovered: totalRecovered,
|
|||
|
|
totalClaimed,
|
|||
|
|
totalPenalty
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обрабатываем проверки контрагентов
|
|||
|
|
if (counterpartiesRes.ok) {
|
|||
|
|
const counterpartiesData = await counterpartiesRes.json();
|
|||
|
|
setCounterpartyChecks(counterpartiesData || []);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error loading legal summary data:', error);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!loading && (contracts.length > 0 || courtCases.length > 0)) {
|
|||
|
|
saveCache(CACHE_KEY, { preTrialStats, contracts, courtCases, counterpartyChecks, efficiency });
|
|||
|
|
}
|
|||
|
|
}, [loading, preTrialStats, contracts, courtCases, counterpartyChecks, efficiency]);
|
|||
|
|
|
|||
|
|
// Статистика договоров
|
|||
|
|
const activeContracts = contracts.filter(c => c.status === 'active').length;
|
|||
|
|
const approvalContracts = contracts.filter(c =>
|
|||
|
|
['draft', 'finance_approval', 'counterparty_approval', 'signing'].includes(c.status)
|
|||
|
|
).length;
|
|||
|
|
|
|||
|
|
// Статистика судебных дел
|
|||
|
|
const activeCourtCases = courtCases.filter(c => c.status !== 'closed').length;
|
|||
|
|
|
|||
|
|
// Зависло у приставов: enforcement и более 20 дней без действий
|
|||
|
|
const stuckBailiffCases = courtCases.filter(c => {
|
|||
|
|
if (c.status !== 'enforcement') return false;
|
|||
|
|
const lastDate = c.fsspLastActionDate;
|
|||
|
|
if (!lastDate) return true;
|
|||
|
|
const days = Math.floor((Date.now() - new Date(lastDate).getTime()) / (1000 * 3600 * 24));
|
|||
|
|
return days > 20;
|
|||
|
|
});
|
|||
|
|
const stuckBailiffSum = stuckBailiffCases.reduce((s, c) => s + (c.amountAtBailiffs || 0), 0);
|
|||
|
|
|
|||
|
|
// Ближайшие заседания (следующие 30 дней)
|
|||
|
|
const upcomingHearings = courtCases
|
|||
|
|
.filter(c => c.nextHearingDate && c.status !== 'closed')
|
|||
|
|
.map(c => ({
|
|||
|
|
...c,
|
|||
|
|
hearingDate: new Date(c.nextHearingDate!)
|
|||
|
|
}))
|
|||
|
|
.filter(c => {
|
|||
|
|
const today = new Date();
|
|||
|
|
const hearingDate = c.hearingDate;
|
|||
|
|
const diffTime = hearingDate.getTime() - today.getTime();
|
|||
|
|
const diffDays = diffTime / (1000 * 60 * 60 * 24);
|
|||
|
|
return diffDays >= 0 && diffDays <= 30;
|
|||
|
|
})
|
|||
|
|
.sort((a, b) => a.hearingDate.getTime() - b.hearingDate.getTime())
|
|||
|
|
.slice(0, 5);
|
|||
|
|
|
|||
|
|
// Статистика проверок контрагентов
|
|||
|
|
const highRiskChecks = counterpartyChecks.filter(c => c.riskLevel === 'high').length;
|
|||
|
|
const recentChecks = counterpartyChecks.filter(c => {
|
|||
|
|
const checkDateStr = c.checkedDate || c.checkedAt;
|
|||
|
|
if (!checkDateStr) return false;
|
|||
|
|
try {
|
|||
|
|
const checkDate = new Date(checkDateStr);
|
|||
|
|
if (isNaN(checkDate.getTime())) return false;
|
|||
|
|
const thirtyDaysAgo = new Date();
|
|||
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|||
|
|
return checkDate >= thirtyDaysAgo;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}).length;
|
|||
|
|
|
|||
|
|
// Форматирование даты для календаря
|
|||
|
|
const formatHearingDate = (dateString: string) => {
|
|||
|
|
const date = new Date(dateString);
|
|||
|
|
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
|
|||
|
|
return {
|
|||
|
|
day: date.getDate().toString().padStart(2, '0'),
|
|||
|
|
month: months[date.getMonth()]
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|||
|
|
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
{/* Main Stats Grid */}
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|||
|
|
<StatCard
|
|||
|
|
icon={FileSignature}
|
|||
|
|
label="На согласовании"
|
|||
|
|
value={approvalContracts}
|
|||
|
|
color="text-blue-600"
|
|||
|
|
bg="bg-blue-50"
|
|||
|
|
onClick={() => onNavigate('contracts')}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={Gavel}
|
|||
|
|
label="Судебных дел"
|
|||
|
|
value={activeCourtCases}
|
|||
|
|
color="text-red-600"
|
|||
|
|
bg="bg-red-50"
|
|||
|
|
onClick={() => onNavigate('courts')}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={FileText}
|
|||
|
|
label="Досудебная работа"
|
|||
|
|
value={preTrialStats.total}
|
|||
|
|
subValue={preTrialStats.inProgress > 0 ? `${preTrialStats.inProgress} в работе` : undefined}
|
|||
|
|
color="text-blue-600"
|
|||
|
|
bg="bg-blue-50"
|
|||
|
|
onClick={() => onNavigate('preTrial')}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={ShieldAlert}
|
|||
|
|
label="Риски (Compliance)"
|
|||
|
|
value={highRiskChecks > 0 ? highRiskChecks : recentChecks}
|
|||
|
|
subValue={highRiskChecks > 0 ? `${highRiskChecks} высокий риск` : `${recentChecks} проверок за месяц`}
|
|||
|
|
color="text-emerald-600"
|
|||
|
|
bg="bg-emerald-50"
|
|||
|
|
onClick={() => onNavigate('compliance')}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|||
|
|
{/* Court Calendar Preview */}
|
|||
|
|
<div className="lg:col-span-2 bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
|
|||
|
|
<div className="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|||
|
|
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest flex items-center gap-2">
|
|||
|
|
<Calendar className="w-4 h-4 text-primary-500"/> Календарь заседаний
|
|||
|
|
</h3>
|
|||
|
|
<span className="text-[10px] font-bold text-primary-600 px-2 py-0.5 bg-white border border-primary-100 rounded-lg">
|
|||
|
|
{upcomingHearings.length} ближайших
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="divide-y divide-slate-100">
|
|||
|
|
{upcomingHearings.length > 0 ? (
|
|||
|
|
upcomingHearings.map(c => {
|
|||
|
|
const dateInfo = formatHearingDate(c.nextHearingDate!);
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={c.id}
|
|||
|
|
className="p-4 flex items-center gap-4 hover:bg-slate-50 transition-colors group cursor-pointer"
|
|||
|
|
onClick={() => onNavigate('courts')}
|
|||
|
|
>
|
|||
|
|
<div className="bg-red-50 text-red-600 p-2 rounded-2xl font-black text-center min-w-[60px] border border-red-100 group-hover:bg-red-600 group-hover:text-white transition-colors">
|
|||
|
|
<p className="text-sm leading-none">{dateInfo.day}</p>
|
|||
|
|
<p className="text-[9px] uppercase mt-1">{dateInfo.month}</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex justify-between items-start">
|
|||
|
|
<p className="text-sm font-bold text-slate-800">{c.caseNumber}</p>
|
|||
|
|
<span className="text-[10px] font-black text-slate-400 bg-slate-100 px-2 py-0.5 rounded uppercase">
|
|||
|
|
{c.amount?.toLocaleString() || 0}₽
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-slate-500 truncate mt-0.5">{c.subject}</p>
|
|||
|
|
{c.judge && (
|
|||
|
|
<p className="text-[10px] text-slate-400 mt-1 font-medium">Судья: {c.judge}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
) : (
|
|||
|
|
<div className="p-8 text-center text-slate-400">
|
|||
|
|
<Calendar className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
|||
|
|
<p className="text-xs font-bold uppercase tracking-widest">Нет ближайших заседаний</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Зависло у приставов */}
|
|||
|
|
{stuckBailiffCases.length > 0 && (
|
|||
|
|
<div
|
|||
|
|
onClick={() => onNavigate('debt')}
|
|||
|
|
className="bg-amber-50 rounded-2xl border-2 border-amber-200 p-4 cursor-pointer hover:border-amber-400 transition-colors"
|
|||
|
|
>
|
|||
|
|
<h4 className="text-xs font-black text-amber-800 uppercase tracking-widest mb-2 flex items-center gap-2">
|
|||
|
|
<AlertTriangle className="w-4 h-4 text-amber-600" /> Зависло у приставов
|
|||
|
|
</h4>
|
|||
|
|
<p className="text-lg font-black text-amber-800">{stuckBailiffCases.length} дел</p>
|
|||
|
|
<p className="text-[11px] text-amber-700 mt-1">{stuckBailiffSum.toLocaleString()} ₽ на депозите</p>
|
|||
|
|
<ul className="mt-3 space-y-1 max-h-24 overflow-y-auto">
|
|||
|
|
{stuckBailiffCases.slice(0, 5).map(c => (
|
|||
|
|
<li key={c.id} className="text-[11px] font-bold text-amber-900 truncate">{c.debtorName || c.caseNumber}</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
<p className="text-[10px] text-amber-600 mt-2 font-black uppercase">Перейти во Взыскание →</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Efficiency Widget */}
|
|||
|
|
<div className="bg-slate-900 rounded-[2.5rem] p-6 text-white shadow-xl relative overflow-hidden flex flex-col justify-between">
|
|||
|
|
<TrendingUp className="absolute -top-4 -right-4 w-40 h-40 opacity-10 rotate-12" />
|
|||
|
|
<div className="relative z-10">
|
|||
|
|
<h4 className="text-xs font-black text-emerald-400 uppercase tracking-widest mb-2">Эффективность</h4>
|
|||
|
|
<p className="text-3xl font-black">{efficiency.percentage}%</p>
|
|||
|
|
<p className="text-[11px] text-slate-400 mt-2 leading-relaxed">
|
|||
|
|
Процент взысканной задолженности от общей суммы исков по делам о взыскании долгов.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-8 space-y-3 relative z-10">
|
|||
|
|
<div className="flex justify-between text-[10px] font-bold uppercase">
|
|||
|
|
<span className="text-slate-500">Взыскано</span>
|
|||
|
|
<span className="text-emerald-400">
|
|||
|
|
{efficiency.recovered >= 1000000
|
|||
|
|
? `${(efficiency.recovered / 1000000).toFixed(1)}M ₽`
|
|||
|
|
: efficiency.recovered >= 1000
|
|||
|
|
? `${(efficiency.recovered / 1000).toFixed(1)}K ₽`
|
|||
|
|
: `${efficiency.recovered.toLocaleString()} ₽`
|
|||
|
|
}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
|||
|
|
<div
|
|||
|
|
className="h-full bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)] transition-all duration-500"
|
|||
|
|
style={{ width: `${Math.min(efficiency.percentage, 100)}%` }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{efficiency.totalClaimed > 0 && (
|
|||
|
|
<div className="text-[9px] text-slate-500 mt-2">
|
|||
|
|
Из {efficiency.totalClaimed.toLocaleString()} ₽ заявлено
|
|||
|
|
{(efficiency.totalPenalty ?? 0) > 0 && (
|
|||
|
|
<span className="block mt-0.5 text-slate-400">в т.ч. пени {(efficiency.totalPenalty || 0).toLocaleString()} ₽</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const StatCard = ({ icon: Icon, label, value, subValue, color, bg, onClick }: any) => (
|
|||
|
|
<div
|
|||
|
|
onClick={onClick}
|
|||
|
|
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm cursor-pointer hover:border-primary-400 transition-all hover:shadow-md active:scale-95"
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-start mb-3">
|
|||
|
|
<div className={`p-2.5 ${bg} ${color} rounded-2xl`}>
|
|||
|
|
<Icon className="w-5 h-5"/>
|
|||
|
|
</div>
|
|||
|
|
{subValue && (
|
|||
|
|
<span className="text-[9px] font-black text-red-500 bg-red-50 px-2 py-1 rounded-full uppercase">
|
|||
|
|
{subValue}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<p className="text-2xl font-black text-slate-800 leading-none">{value}</p>
|
|||
|
|
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-2">{label}</p>
|
|||
|
|
</div>
|
|||
|
|
);
|