Files
mkd/components/legal/LegalSummary.tsx

393 lines
19 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);