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

596 lines
32 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, useMemo } from 'react';
import { LegalCourtCase } from '../../types';
import { Gavel, ExternalLink, Calendar, CreditCard, Landmark, ChevronRight, Plus, X, Loader2, Search } from 'lucide-react';
import { authFetch } from '../../services/apiClient';
import { CaseDetailsModal } from './CaseDetailsModal';
/** Ссылка на Картотеку арбитражных дел (КАД) или сайт судов общей юрисдикции (СОЮ) */
function getCourtCaseExternalUrl(type: string, caseNumber: string): { url: string; label: string } {
const encoded = encodeURIComponent(caseNumber.trim());
if (type === 'arbitration') {
return { url: `https://kad.arbitr.ru/?q=${encoded}`, label: 'КАД' };
}
return { url: 'https://sudrf.ru/', label: 'СОЮ' };
}
type SubTab = 'all' | 'debtors' | 'others';
export const CourtCases: React.FC = () => {
const [cases, setCases] = useState<LegalCourtCase[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingCase, setEditingCase] = useState<LegalCourtCase | null>(null);
const [selectedCase, setSelectedCase] = useState<LegalCourtCase | null>(null);
const [showCaseDetailsModal, setShowCaseDetailsModal] = useState(false);
const [subTab, setSubTab] = useState<SubTab>('all');
const [statusFilter, setStatusFilter] = useState<string>('');
const [roleFilter, setRoleFilter] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const loadCases = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (searchQuery.trim()) params.set('search', searchQuery.trim());
const response = await authFetch(`/api/legal/court-cases${params.toString() ? '?' + params.toString() : ''}`);
if (response.ok) {
const data = await response.json();
setCases(Array.isArray(data) ? data : []);
} else {
setCases([]);
}
} catch (error) {
console.error('Error loading court cases:', error);
setCases([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (searchQuery === '') {
loadCases();
return;
}
const t = setTimeout(() => loadCases(), 300);
return () => clearTimeout(t);
}, [searchQuery]);
const filteredCases = useMemo(() => {
let list = cases;
if (subTab === 'debtors') list = list.filter(c => c.type === 'debt_recovery');
else if (subTab === 'others') list = list.filter(c => c.type === 'arbitration' || c.type === 'civil');
if (statusFilter) list = list.filter(c => c.status === statusFilter);
if (roleFilter) list = list.filter(c => c.role === roleFilter);
return list;
}, [cases, subTab, statusFilter, roleFilter]);
const countsByType = useMemo(() => ({
arbitration: filteredCases.filter(c => c.type === 'arbitration').length,
civil: filteredCases.filter(c => c.type === 'civil').length,
debt_recovery: filteredCases.filter(c => c.type === 'debt_recovery').length
}), [filteredCases]);
const upcomingHearings = useMemo(() => {
const today = new Date();
const from = new Date(today);
const to = new Date(today);
to.setDate(to.getDate() + 30);
return filteredCases
.filter(c => c.nextHearingDate && c.status !== 'closed')
.map(c => ({ ...c, hearingDate: new Date(c.nextHearingDate!) }))
.filter(c => c.hearingDate >= from && c.hearingDate <= to)
.sort((a, b) => a.hearingDate.getTime() - b.hearingDate.getTime())
.slice(0, 5);
}, [filteredCases]);
const handleCreate = () => {
setEditingCase(null);
setShowModal(true);
};
const handleEdit = (courtCase: LegalCourtCase) => {
setEditingCase(courtCase);
setShowModal(true);
};
const handleOpenCard = (courtCase: LegalCourtCase) => {
setSelectedCase(courtCase);
setShowCaseDetailsModal(true);
};
const handleCaseDetailsUpdate = (updatedCase: LegalCourtCase) => {
setSelectedCase(updatedCase);
setCases(prev => prev.map(c => c.id === updatedCase.id ? { ...c, ...updatedCase } : c));
};
const handleSave = async (caseData: Partial<LegalCourtCase>) => {
try {
if (editingCase) {
// Обновление
const response = await authFetch(`/api/legal/court-cases/${editingCase.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(caseData)
});
if (response.ok) {
await loadCases();
setShowModal(false);
setEditingCase(null);
}
} else {
// Создание
const response = await authFetch('/api/legal/court-cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(caseData)
});
if (response.ok) {
await loadCases();
setShowModal(false);
}
}
} catch (error) {
console.error('Error saving court case:', error);
alert('Ошибка при сохранении судебного дела');
}
};
const handleExport = () => {
const headers = ['Номер дела', 'Тип', 'Роль', 'Предмет', 'Должник', 'Адрес', 'Сумма', 'Статус', 'Дата заседания', 'Судья', 'Суд', 'Взыскано', 'У приставов'];
const statusLabel = (s: string) => ({ pre_trial: 'Досудебное', litigation: 'В суде', decision_received: 'Решение получено', enforcement: 'ФССП', closed: 'Закрыто' })[s] || s;
const typeLabel = (t: string) => ({ arbitration: 'Арбитраж', civil: 'СОЮ', debt_recovery: 'Взыскание долга' })[t] || t;
const roleLabel = (r: string) => ({ plaintiff: 'Истец', defendant: 'Ответчик' })[r] || r;
const escape = (v: string | number | undefined) => {
const s = String(v ?? '');
return s.includes(';') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
};
const rows = filteredCases.map(c => [
escape(c.caseNumber),
escape(typeLabel(c.type)),
escape(roleLabel(c.role)),
escape(c.subject),
escape(c.debtorName),
escape(c.address),
escape(c.amount),
escape(statusLabel(c.status)),
escape(c.nextHearingDate),
escape(c.judge),
escape(c.courtName ?? ''),
escape(c.recoveredAmount ?? ''),
escape(c.amountAtBailiffs ?? '')
].join(';'));
const csv = '\uFEFF' + [headers.join(';'), ...rows].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `court-cases-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex justify-between items-center px-1">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Судебная практика УК</h3>
<div className="flex gap-2">
<button
onClick={handleCreate}
className="bg-primary-600 text-white px-4 py-2 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 text-xs font-black uppercase tracking-wider active:scale-95 transition-all"
>
<Plus className="w-4 h-4" /> Создать дело
</button>
<button onClick={handleExport} className="text-[10px] font-black text-primary-600 uppercase bg-primary-50 px-3 py-1 rounded-lg hover:bg-primary-100 transition-colors">Выгрузить отчет</button>
</div>
</div>
{/* Подтабы */}
<div className="flex p-1 bg-slate-200/50 rounded-2xl gap-1">
{[
{ id: 'all' as SubTab, label: 'Все' },
{ id: 'debtors' as SubTab, label: 'По должникам' },
{ id: 'others' as SubTab, label: 'Другие' }
].map(tab => (
<button
key={tab.id}
onClick={() => setSubTab(tab.id)}
className={`flex-1 py-2.5 px-4 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${subTab === tab.id ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{tab.label}
</button>
))}
</div>
{subTab === 'debtors' && (
<p className="text-[10px] text-slate-500 px-1">
Подробный пайплайн и контроль приставов во вкладке <strong>Взыскание</strong>.
</p>
)}
{/* Фильтры и поиск */}
<div className="flex flex-wrap gap-3 items-center px-1">
<div className="relative flex-1 min-w-[12rem]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по номеру, предмету, должнику..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="px-4 py-2.5 border border-slate-200 rounded-xl text-sm bg-white outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все статусы</option>
<option value="pre_trial">Досудебное</option>
<option value="litigation">В суде</option>
<option value="decision_received">Решение получено</option>
<option value="enforcement">ФССП</option>
<option value="closed">Закрыто</option>
</select>
<select
value={roleFilter}
onChange={e => setRoleFilter(e.target.value)}
className="px-4 py-2.5 border border-slate-200 rounded-xl text-sm bg-white outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все роли</option>
<option value="plaintiff">Истец</option>
<option value="defendant">Ответчик</option>
</select>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : (
<>
{/* Сводка: счётчики по типам и ближайшие заседания */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-3">Дела по типам</p>
<div className="flex gap-4 flex-wrap">
<span className="text-sm font-bold text-slate-700">Арбитраж: <strong className="text-slate-900">{countsByType.arbitration}</strong></span>
<span className="text-sm font-bold text-slate-700">СОЮ: <strong className="text-slate-900">{countsByType.civil}</strong></span>
<span className="text-sm font-bold text-slate-700">Взыскание долга: <strong className="text-slate-900">{countsByType.debt_recovery}</strong></span>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-3">Ближайшие заседания (30 дней)</p>
{upcomingHearings.length === 0 ? (
<p className="text-sm text-slate-500">Нет назначенных заседаний</p>
) : (
<ul className="space-y-2">
{upcomingHearings.map(c => (
<li
key={c.id}
onClick={() => handleOpenCard(c)}
className="flex justify-between items-center text-sm cursor-pointer hover:bg-slate-50 rounded-lg px-2 py-1 -mx-2 -my-1"
>
<span className="font-bold text-slate-800 truncate flex-1">{c.caseNumber}</span>
<span className="text-slate-500 shrink-0 ml-2">{c.nextHearingDate}</span>
</li>
))}
</ul>
)}
</div>
</div>
<div className="space-y-4">
{filteredCases.map(c => (
<div
key={c.id}
className="bg-white rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden group hover:border-red-200 transition-all cursor-pointer"
onClick={() => handleOpenCard(c)}
>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className={`p-3 rounded-2xl ${c.role === 'plaintiff' ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
<Landmark className="w-6 h-6"/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-black text-slate-900">{c.caseNumber}</span>
{(() => {
const { url, label } = getCourtCaseExternalUrl(c.type, c.caseNumber);
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:text-blue-700 transition-colors text-[10px] font-black uppercase" onClick={(e) => e.stopPropagation()} title={`Открыть в ${label}`}>
{label} <ExternalLink className="w-3.5 h-3.5 inline"/>
</a>
);
})()}
</div>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider mt-1">
{c.type === 'arbitration' ? 'Арбитражный суд' : c.type === 'civil' ? 'Суд общей юрисдикции' : 'Взыскание долга'}
</p>
</div>
</div>
<span className={`text-[10px] font-black px-2.5 py-1 rounded-full uppercase border ${c.role === 'plaintiff' ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-red-50 text-red-600 border-red-100'}`}>
{c.role === 'plaintiff' ? 'Истец' : 'Ответчик'}
</span>
</div>
<p className="text-sm font-bold text-slate-700 leading-snug mb-6">{c.subject}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100">
<p className="text-[9px] font-black text-slate-400 uppercase mb-1 flex items-center gap-1">
<CreditCard className="w-3 h-3"/> Сумма иска
</p>
<p className="text-base font-black text-slate-800">{c.amount.toLocaleString()} </p>
</div>
<div className={`p-4 rounded-2xl border ${c.nextHearingDate ? 'bg-red-50 border-red-100' : 'bg-slate-50 border-slate-100'}`}>
<p className={`text-[9px] font-black uppercase mb-1 flex items-center gap-1 ${c.nextHearingDate ? 'text-red-400' : 'text-slate-400'}`}>
<Calendar className="w-3 h-3"/> Заседание
</p>
<p className={`text-base font-black ${c.nextHearingDate ? 'text-red-600' : 'text-slate-400'}`}>
{c.nextHearingDate || 'Не назначено'}
</p>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-between items-center group-hover:bg-slate-100 transition-colors">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-slate-400 uppercase">Статус:</span>
<span className="text-xs font-bold text-slate-700">
{c.status === 'pre_trial' ? 'Досудебное' :
c.status === 'litigation' ? 'Судебный процесс' :
c.status === 'decision_received' ? 'Решение получено' :
c.status === 'enforcement' ? 'ФССП' : 'Завершено'}
</span>
</div>
<button
className="text-[10px] font-black text-primary-600 uppercase flex items-center gap-1.5 hover:underline"
onClick={(e) => {
e.stopPropagation();
handleEdit(c);
}}
title="Открыть форму редактирования"
>
Редактировать <ChevronRight className="w-4 h-4"/>
</button>
</div>
</div>
))}
{filteredCases.length === 0 && (
<div className="py-20 text-center text-slate-400">
<Gavel className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p className="font-bold uppercase tracking-widest text-xs mb-4">Судебные дела не найдены</p>
<button
onClick={handleCreate}
className="bg-primary-600 text-white px-6 py-2 rounded-xl text-xs font-black uppercase"
>
Создать первое дело
</button>
</div>
)}
</div>
</>
)}
{/* Court Case Modal */}
{showModal && (
<CourtCaseModal
key={editingCase?.id ?? 'new'}
courtCase={editingCase}
onClose={() => {
setShowModal(false);
setEditingCase(null);
}}
onSave={handleSave}
/>
)}
{showCaseDetailsModal && selectedCase && (
<CaseDetailsModal
courtCase={selectedCase}
onClose={() => {
setShowCaseDetailsModal(false);
setSelectedCase(null);
}}
onUpdate={handleCaseDetailsUpdate}
/>
)}
</div>
);
};
interface CourtCaseModalProps {
courtCase: LegalCourtCase | null;
onClose: () => void;
onSave: (data: Partial<LegalCourtCase>) => Promise<void>;
}
const CourtCaseModal: React.FC<CourtCaseModalProps> = ({ courtCase, onClose, onSave }) => {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
caseNumber: courtCase?.caseNumber || '',
type: courtCase?.type || 'debt_recovery',
role: courtCase?.role || 'plaintiff',
subject: courtCase?.subject || '',
debtorName: courtCase?.debtorName || '',
address: courtCase?.address || '',
amount: courtCase?.amount || 0,
nextHearingDate: courtCase?.nextHearingDate || '',
judge: courtCase?.judge || '',
courtName: courtCase?.courtName ?? '',
notes: courtCase?.notes ?? ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await onSave(formData);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">
{courtCase ? 'Редактировать судебное дело' : 'Создать судебное дело'}
</h3>
<button onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Номер дела *</label>
<input
type="text"
value={formData.caseNumber}
onChange={(e) => setFormData({ ...formData, caseNumber: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
placeholder="А40-12345/2024"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Тип суда *</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
required
>
<option value="arbitration">Арбитражный суд</option>
<option value="civil">Суд общей юрисдикции</option>
<option value="debt_recovery">Взыскание долга</option>
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Роль *</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
required
>
<option value="plaintiff">Истец</option>
<option value="defendant">Ответчик</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Предмет дела *</label>
<textarea
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Должник</label>
<input
type="text"
value={formData.debtorName}
onChange={(e) => setFormData({ ...formData, debtorName: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Адрес</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Сумма иска *</label>
<input
type="number"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
min="0"
step="0.01"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Дата заседания</label>
<input
type="date"
value={formData.nextHearingDate}
onChange={(e) => setFormData({ ...formData, nextHearingDate: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Судья</label>
<input
type="text"
value={formData.judge}
onChange={(e) => setFormData({ ...formData, judge: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Название суда</label>
<input
type="text"
value={formData.courtName}
onChange={(e) => setFormData({ ...formData, courtName: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Примечания</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-xs font-black uppercase hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
);
};