Files
mkd/components/legal/LegalSummary.tsx
2026-02-04 00:17:04 +05:00

393 lines
19 KiB
TypeScript
Executable File
Raw 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 {
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>
);