Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

View File

@@ -0,0 +1,630 @@
import React, { useState, useEffect } from 'react';
import { LegalCourtCase } from '../../types';
import { FileText, History, MessageSquare, User, X, Edit, Save, ExternalLink } from 'lucide-react';
import { authFetch } from '../../services/apiClient';
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: 'СОЮ' };
}
export interface CaseDetailsModalProps {
courtCase: LegalCourtCase;
onClose: () => void;
onUpdate: (updatedCase: LegalCourtCase) => void;
}
const DOC_TYPE_LABELS: Record<string, string> = {
pretenzia: 'Претензия',
isk: 'Исковое заявление',
reshenie: 'Решение суда',
ispolnitelny_list: 'Исполнительный лист',
postanovlenie_ip: 'Постановление об ИП',
other: 'Прочее'
};
export const CaseDetailsModal: React.FC<CaseDetailsModalProps> = ({ courtCase, onUpdate, onClose }) => {
const [activeTab, setActiveTab] = useState<'info' | 'history' | 'comments' | 'documents'>('info');
const [loading, setLoading] = useState(false);
const [history, setHistory] = useState<any[]>([]);
const [comments, setComments] = useState<any[]>([]);
const [documents, setDocuments] = useState<any[]>([]);
const [newComment, setNewComment] = useState('');
const [commentAuthor, setCommentAuthor] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [newDoc, setNewDoc] = useState({ docType: 'isk', fileUrl: '', docDate: '', title: '' });
const [docLoading, setDocLoading] = useState(false);
const [formData, setFormData] = useState({
amount: courtCase.amount,
recoveredAmount: courtCase.recoveredAmount || 0,
status: courtCase.status,
nextHearingDate: courtCase.nextHearingDate || '',
judge: courtCase.judge || '',
courtName: courtCase.courtName ?? '',
notes: courtCase.notes ?? '',
penaltyAmount: courtCase.penaltyAmount ?? 0,
overdueSince: courtCase.overdueSince ?? ''
});
useEffect(() => {
loadHistory();
loadComments();
loadDocuments();
}, [courtCase.id]);
const loadHistory = async () => {
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/history`);
if (response.ok) {
const data = await response.json();
setHistory(data || []);
}
} catch (error) {
console.error('Error loading history:', error);
}
};
const loadComments = async () => {
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/comments`);
if (response.ok) {
const data = await response.json();
setComments(data || []);
}
} catch (error) {
console.error('Error loading comments:', error);
}
};
const loadDocuments = async () => {
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/documents`);
if (response.ok) {
const data = await response.json();
setDocuments(Array.isArray(data) ? data : []);
}
} catch (error) {
console.error('Error loading documents:', error);
}
};
const handleAddDocument = async () => {
if (!newDoc.fileUrl.trim()) {
alert('Укажите ссылку на документ');
return;
}
setDocLoading(true);
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/documents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
docType: newDoc.docType,
fileUrl: newDoc.fileUrl.trim(),
docDate: newDoc.docDate || null,
title: newDoc.title.trim() || null
})
});
if (response.ok) {
setNewDoc({ docType: 'isk', fileUrl: '', docDate: '', title: '' });
await loadDocuments();
} else {
alert('Ошибка при добавлении документа');
}
} catch (error) {
console.error('Error adding document:', error);
alert('Ошибка при добавлении документа');
} finally {
setDocLoading(false);
}
};
const handleDeleteDocument = async (docId: number) => {
if (!confirm('Удалить документ?')) return;
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/documents/${docId}`, { method: 'DELETE' });
if (response.ok) await loadDocuments();
} catch (error) {
console.error('Error deleting document:', error);
}
};
const handleSave = async () => {
setLoading(true);
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
changedBy: commentAuthor || 'System'
})
});
if (response.ok) {
const updated = await response.json();
onUpdate(updated);
setIsEditing(false);
await loadHistory();
} else {
alert('Ошибка при сохранении');
}
} catch (error) {
console.error('Error saving case:', error);
alert('Ошибка при сохранении');
} finally {
setLoading(false);
}
};
const handleAddComment = async () => {
if (!newComment.trim() || !commentAuthor.trim()) {
alert('Заполните автора и комментарий');
return;
}
setLoading(true);
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
author: commentAuthor,
comment: newComment
})
});
if (response.ok) {
setNewComment('');
await loadComments();
} else {
alert('Ошибка при добавлении комментария');
}
} catch (error) {
console.error('Error adding comment:', error);
alert('Ошибка при добавлении комментария');
} finally {
setLoading(false);
}
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
'pre_trial': 'Досудебное',
'litigation': 'Судебный процесс',
'decision_received': 'Решение получено',
'enforcement': 'ФССП',
'closed': 'Завершено'
};
return labels[status] || status;
};
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-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-black text-slate-800">Карточка дела</h3>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-slate-500">{courtCase.caseNumber}</p>
{(() => {
const { url, label } = getCourtCaseExternalUrl(courtCase.type, courtCase.caseNumber);
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700 text-xs font-black uppercase flex items-center gap-1" title={`Открыть в ${label}`}>
{label} <ExternalLink className="w-3.5 h-3.5" />
</a>
);
})()}
</div>
</div>
<button onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex gap-2 mb-6 border-b border-slate-200">
{[
{ id: 'info', label: 'Основная информация', icon: FileText },
{ id: 'history', label: 'История', icon: History },
{ id: 'comments', label: 'Комментарии', icon: MessageSquare },
{ id: 'documents', label: 'Документы', icon: FileText }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-4 py-2 text-xs font-black uppercase transition-all border-b-2 ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-400 hover:text-slate-600'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{activeTab === 'info' && (
<div className="space-y-4">
{!isEditing ? (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Предмет дела</label>
<p className="text-sm text-slate-800">{courtCase.subject}</p>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Статус</label>
<span className="inline-block px-3 py-1 rounded-full text-xs font-black bg-blue-100 text-blue-600">
{getStatusLabel(courtCase.status)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Должник</label>
<p className="text-sm text-slate-800">{courtCase.debtorName || 'Не указан'}</p>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Адрес</label>
<p className="text-sm text-slate-800">{courtCase.address || 'Не указан'}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Сумма иска</label>
<p className="text-lg font-black text-slate-900">{courtCase.amount.toLocaleString()} </p>
{(courtCase.penaltyAmount ?? 0) > 0 && (
<p className="text-[10px] text-slate-500 mt-0.5">в т.ч. пени {(courtCase.penaltyAmount || 0).toLocaleString()} </p>
)}
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Взыскано</label>
<p className="text-lg font-black text-emerald-600">{(courtCase.recoveredAmount || 0).toLocaleString()} </p>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">У приставов</label>
<p className="text-lg font-black text-amber-600">{(courtCase.amountAtBailiffs || 0).toLocaleString()} </p>
</div>
</div>
{(courtCase.overdueSince || (courtCase.penaltyAmount ?? 0) > 0) && (
<div className="grid grid-cols-2 gap-4">
{courtCase.overdueSince && (
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Просрочка с</label>
<p className="text-sm text-slate-800">{courtCase.overdueSince}</p>
</div>
)}
{(courtCase.penaltyAmount ?? 0) > 0 && (
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Пени</label>
<p className="text-sm font-bold text-slate-800">{(courtCase.penaltyAmount || 0).toLocaleString()} </p>
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Суд</label>
<p className="text-sm text-slate-800">{courtCase.courtName || 'Не указан'}</p>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Судья</label>
<p className="text-sm text-slate-800">{courtCase.judge || 'Не указан'}</p>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Дата заседания</label>
<p className="text-sm text-slate-800">{courtCase.nextHearingDate || 'Не назначено'}</p>
</div>
{courtCase.notes && (
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Примечания</label>
<p className="text-sm text-slate-600 bg-slate-50 p-3 rounded-xl">{courtCase.notes}</p>
</div>
)}
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 transition-colors"
>
<Edit className="w-4 h-4" /> Редактировать
</button>
</>
) : (
<div className="space-y-4">
<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="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"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Статус *</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: 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"
>
<option value="pre_trial">Досудебное</option>
<option value="litigation">Судебный процесс</option>
<option value="decision_received">Решение получено</option>
<option value="enforcement">ФССП</option>
<option value="closed">Завершено</option>
</select>
</div>
</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="number"
value={formData.recoveredAmount}
onChange={(e) => setFormData({ ...formData, recoveredAmount: 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"
/>
</div>
<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>
<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.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>
<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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Пени ()</label>
<input
type="number"
min={0}
value={formData.penaltyAmount}
onChange={(e) => setFormData({ ...formData, penaltyAmount: 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"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Просрочка с (дата)</label>
<input
type="date"
value={formData.overdueSince}
onChange={(e) => setFormData({ ...formData, overdueSince: 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>
<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>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Кто вносит изменения</label>
<input
type="text"
value={commentAuthor}
onChange={(e) => setCommentAuthor(e.target.value)}
placeholder="Ваше имя"
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">
<button
onClick={handleSave}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" /> {loading ? 'Сохранение...' : 'Сохранить'}
</button>
<button
onClick={() => {
setIsEditing(false);
setFormData({
amount: courtCase.amount,
recoveredAmount: courtCase.recoveredAmount || 0,
status: courtCase.status,
nextHearingDate: courtCase.nextHearingDate || '',
judge: courtCase.judge || '',
courtName: courtCase.courtName ?? '',
notes: courtCase.notes ?? '',
penaltyAmount: courtCase.penaltyAmount ?? 0,
overdueSince: courtCase.overdueSince ?? ''
});
}}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-xl text-xs font-black uppercase hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
)}
</div>
)}
{activeTab === 'history' && (
<div className="space-y-3">
{history.length > 0 ? (
history.map((h: any) => (
<div key={h.id} className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-sm font-black text-slate-800">
{h.fromStatus ? `${getStatusLabel(h.fromStatus)}${getStatusLabel(h.toStatus)}` : `Создано: ${getStatusLabel(h.toStatus)}`}
</p>
<p className="text-xs text-slate-500 mt-1">Изменил: {h.changedBy}</p>
</div>
<p className="text-xs text-slate-400">
{new Date(h.createdAt).toLocaleDateString('ru-RU')}
</p>
</div>
{h.changeReason && (
<p className="text-xs text-slate-600 mt-2">{h.changeReason}</p>
)}
</div>
))
) : (
<div className="text-center py-12 text-slate-400">
<History className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-xs font-bold uppercase">История изменений пуста</p>
</div>
)}
</div>
)}
{activeTab === 'comments' && (
<div className="space-y-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{comments.length > 0 ? (
comments.map((comment: any) => (
<div key={comment.id} className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-slate-400" />
<p className="text-sm font-black text-slate-800">{comment.author}</p>
</div>
<p className="text-xs text-slate-400">
{new Date(comment.createdAt).toLocaleDateString('ru-RU')}
</p>
</div>
<p className="text-sm text-slate-700">{comment.comment}</p>
</div>
))
) : (
<div className="text-center py-12 text-slate-400">
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-xs font-bold uppercase">Комментариев пока нет</p>
</div>
)}
</div>
<div className="border-t border-slate-200 pt-4">
<div className="space-y-3">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Автор</label>
<input
type="text"
value={commentAuthor}
onChange={(e) => setCommentAuthor(e.target.value)}
placeholder="Ваше имя"
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={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={3}
placeholder="Введите комментарий..."
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>
<button
onClick={handleAddComment}
disabled={loading || !newComment.trim() || !commentAuthor.trim()}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 transition-colors disabled:opacity-50"
>
{loading ? 'Добавление...' : 'Добавить комментарий'}
</button>
</div>
</div>
</div>
)}
{activeTab === 'documents' && (
<div className="space-y-4">
{courtCase.caseFileUrl && (
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<a
href={courtCase.caseFileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700 font-bold"
>
<FileText className="w-5 h-5" />
Материалы дела (общая ссылка)
</a>
</div>
)}
<div>
<h4 className="text-xs font-black text-slate-700 uppercase mb-2">Документы по типам</h4>
{documents.length > 0 ? (
<div className="space-y-2">
{documents.map((doc: any) => (
<div key={doc.id} className="bg-slate-50 p-3 rounded-xl border border-slate-200 flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<span className="text-[10px] font-black text-slate-500 uppercase mr-2">{DOC_TYPE_LABELS[doc.docType] || doc.docType}</span>
{doc.docDate && <span className="text-[10px] text-slate-400">{doc.docDate}</span>}
<a href={doc.fileUrl} target="_blank" rel="noopener noreferrer" className="block text-sm font-bold text-primary-600 hover:underline truncate mt-0.5">
{doc.title || 'Ссылка на документ'}
</a>
</div>
<button type="button" onClick={() => handleDeleteDocument(doc.id)} className="p-1.5 text-slate-400 hover:text-red-600 rounded-lg" title="Удалить">×</button>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-400">Нет документов по типам</p>
)}
</div>
<div className="border-t border-slate-200 pt-4">
<h4 className="text-xs font-black text-slate-700 uppercase mb-3">Добавить документ</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black text-slate-600 uppercase mb-1">Тип</label>
<select value={newDoc.docType} onChange={(e) => setNewDoc({ ...newDoc, docType: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500">
{Object.entries(DOC_TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-[10px] font-black text-slate-600 uppercase mb-1">Дата документа</label>
<input type="date" value={newDoc.docDate} onChange={(e) => setNewDoc({ ...newDoc, docDate: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500" />
</div>
</div>
<div className="mt-3">
<label className="block text-[10px] font-black text-slate-600 uppercase mb-1">Ссылка на файл *</label>
<input type="url" value={newDoc.fileUrl} onChange={(e) => setNewDoc({ ...newDoc, fileUrl: e.target.value })} placeholder="https://..." className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div className="mt-3">
<label className="block text-[10px] font-black text-slate-600 uppercase mb-1">Название (необязательно)</label>
<input type="text" value={newDoc.title} onChange={(e) => setNewDoc({ ...newDoc, title: e.target.value })} placeholder="Краткое описание" className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<button type="button" onClick={handleAddDocument} disabled={docLoading || !newDoc.fileUrl.trim()} className="mt-3 px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 disabled:opacity-50">
{docLoading ? 'Добавление...' : 'Добавить документ'}
</button>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,852 @@
import React, { useState } from 'react';
// FIX: Added missing ChevronRight import
import { Search, ShieldCheck, AlertTriangle, Info, ShieldAlert, CheckCircle2, History, ChevronRight, Loader2, X, FileText, Phone, Mail } from 'lucide-react';
import { authFetch } from '../../services/apiClient';
interface LicenseItem {
number?: string;
series?: string;
validFrom?: number;
validTo?: number;
activities?: string[];
issueAuthority?: string;
}
interface CounterpartyCheckResult {
inn: string;
kpp?: string;
ogrn?: string;
name: string;
shortName?: string;
type: string;
status: string;
registrationDate?: string;
liquidationDate?: string;
address?: string;
okved?: string;
okveds?: any[];
management?: {
name: string;
post?: string;
};
finance?: {
taxSystem?: string;
income?: number;
revenue?: number;
expense?: number;
debt?: number;
penalty?: number;
year?: number;
};
authorities?: any;
phones?: any[];
emails?: any[];
employeeCount?: number;
capital?: { type?: string; value?: number };
smb?: { category?: string; issueDate?: number };
licenses?: LicenseItem[];
addressInvalidity?: any;
foundersInvalidity?: Array<{ name?: string; invalidity?: any }>;
managersInvalidity?: Array<{ name?: string; post?: string; invalidity?: any }>;
managementDisqualified?: boolean;
riskLevel: 'low' | 'medium' | 'high';
riskReasons: string[];
checkedDate: string;
rawData?: any;
}
export const ComplianceCheck: React.FC = () => {
const [inn, setInn] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checkResult, setCheckResult] = useState<CounterpartyCheckResult | null>(null);
const [checkedCounterparties, setCheckedCounterparties] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(true);
const [selectedReport, setSelectedReport] = useState<any | null>(null);
const [showReportModal, setShowReportModal] = useState(false);
// Загружаем историю проверок при монтировании компонента
React.useEffect(() => {
loadHistory();
}, []);
const loadHistory = async () => {
try {
setLoadingHistory(true);
const response = await authFetch('/api/legal/counterparties');
if (response.ok) {
const data = await response.json();
setCheckedCounterparties(data);
}
} catch (error) {
console.error('Error loading history:', error);
} finally {
setLoadingHistory(false);
}
};
const handleCheck = async () => {
if (!inn.trim()) {
setError('Введите ИНН организации');
return;
}
setLoading(true);
setError(null);
setCheckResult(null);
try {
const response = await authFetch('/api/legal/check-counterparty', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ inn: inn.trim() })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Ошибка при проверке контрагента');
}
const result = await response.json();
setCheckResult(result);
// Обновляем историю проверок
await loadHistory();
} catch (err) {
console.error('Error checking counterparty:', err);
setError(err instanceof Error ? err.message : 'Ошибка при проверке контрагента');
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleCheck();
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* Search Tool */}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<ShieldCheck className="absolute -bottom-4 -right-4 w-48 h-48 opacity-10 rotate-12" />
<div className="relative z-10 max-w-lg">
<h3 className="text-2xl font-black mb-2">Проверка контрагента</h3>
<p className="text-xs text-slate-400 font-medium mb-6">Автоматическая сверка по базам ФНС через DaData API.</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Введите ИНН организации (10 или 12 цифр)..."
value={inn}
onChange={(e) => {
setInn(e.target.value.replace(/\D/g, ''));
setError(null);
}}
onKeyPress={handleKeyPress}
className="flex-1 px-5 py-3 bg-white/10 border border-white/20 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 transition-all placeholder:text-slate-500"
maxLength={12}
/>
<button
onClick={handleCheck}
disabled={loading || !inn.trim()}
className="bg-white text-slate-900 px-6 py-3 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-slate-100 transition-all active:scale-95 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Проверка...
</>
) : (
'Проверить'
)}
</button>
</div>
{error && (
<div className="mt-4 p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
{error}
</div>
)}
</div>
</div>
{/* Check Result */}
{checkResult && (
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-lg p-6">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-4">
<div className={`p-4 rounded-2xl ${
checkResult.riskLevel === 'high' ? 'bg-red-50 text-red-600' :
checkResult.riskLevel === 'medium' ? 'bg-amber-50 text-amber-600' :
'bg-emerald-50 text-emerald-600'
}`}>
{checkResult.riskLevel === 'high' ? <ShieldAlert className="w-7 h-7" /> :
checkResult.riskLevel === 'medium' ? <AlertTriangle className="w-7 h-7" /> :
<CheckCircle2 className="w-7 h-7" />}
</div>
<div>
<h4 className="font-black text-slate-800 text-lg leading-tight">{checkResult.name}</h4>
<p className="text-xs text-slate-500 mt-1">
ИНН: {checkResult.inn}
{checkResult.kpp && ` • КПП: ${checkResult.kpp}`}
{checkResult.ogrn && ` • ОГРН: ${checkResult.ogrn}`}
</p>
</div>
</div>
<button
onClick={() => setCheckResult(null)}
className="p-2 text-slate-400 hover:text-slate-600"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Risk Level */}
<div className={`p-4 rounded-xl border-2 ${
checkResult.riskLevel === 'high' ? 'bg-red-50 border-red-200' :
checkResult.riskLevel === 'medium' ? 'bg-amber-50 border-amber-200' :
'bg-emerald-50 border-emerald-200'
}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-black text-slate-800">Уровень риска:</span>
<span className={`text-sm font-black uppercase ${
checkResult.riskLevel === 'high' ? 'text-red-600' :
checkResult.riskLevel === 'medium' ? 'text-amber-600' :
'text-emerald-600'
}`}>
{checkResult.riskLevel === 'high' ? 'Высокий риск' :
checkResult.riskLevel === 'medium' ? 'Средний риск' :
'Благонадежен'}
</span>
</div>
{checkResult.riskReasons.length > 0 && (
<ul className="text-xs text-slate-700 mt-2 space-y-1">
{checkResult.riskReasons.map((reason, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
<span>{reason}</span>
</li>
))}
</ul>
)}
</div>
{/* Status */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Статус</p>
<p className="text-sm font-bold text-slate-800">
{checkResult.status === 'ACTIVE' ? 'Действующая' :
checkResult.status === 'LIQUIDATED' ? 'Ликвидирована' :
checkResult.status === 'LIQUIDATING' ? 'Ликвидируется' :
checkResult.status === 'BANKRUPT' ? 'Банкрот' :
checkResult.status === 'REORGANIZING' ? 'Реорганизуется' :
checkResult.status}
</p>
</div>
{checkResult.registrationDate && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Дата регистрации</p>
<p className="text-sm font-bold text-slate-800">
{new Date(checkResult.registrationDate).toLocaleDateString('ru-RU')}
</p>
</div>
)}
</div>
{/* Address */}
{checkResult.address && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Адрес</p>
<p className="text-sm text-slate-700">{checkResult.address}</p>
</div>
)}
{/* Finance */}
{checkResult.finance && (
<div className="bg-slate-50 rounded-xl p-4">
<p className="text-xs font-black text-slate-500 uppercase mb-3">Финансовые показатели</p>
<div className="grid grid-cols-2 gap-4 text-sm">
{checkResult.finance.revenue && (
<div>
<p className="text-slate-500">Выручка ({checkResult.finance.year})</p>
<p className="font-bold text-slate-800">
{checkResult.finance.revenue.toLocaleString('ru-RU')}
</p>
</div>
)}
{checkResult.finance.income && (
<div>
<p className="text-slate-500">Доходы ({checkResult.finance.year})</p>
<p className="font-bold text-slate-800">
{checkResult.finance.income.toLocaleString('ru-RU')}
</p>
</div>
)}
{checkResult.finance.debt && checkResult.finance.debt > 0 && (
<div>
<p className="text-red-600">Недоимки</p>
<p className="font-bold text-red-600">
{checkResult.finance.debt.toLocaleString('ru-RU')}
</p>
</div>
)}
{checkResult.finance.penalty && checkResult.finance.penalty > 0 && (
<div>
<p className="text-red-600">Штрафы</p>
<p className="font-bold text-red-600">
{checkResult.finance.penalty.toLocaleString('ru-RU')}
</p>
</div>
)}
</div>
</div>
)}
{/* Management */}
{checkResult.management && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Руководитель</p>
<p className="text-sm text-slate-700">
{checkResult.management.name}
{checkResult.management.post && `${checkResult.management.post}`}
</p>
</div>
)}
{/* OKVED */}
{checkResult.okved && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Основной ОКВЭД</p>
<p className="text-sm text-slate-700">
{checkResult.okved} {checkResult.okveds?.find(o => o.main)?.name || ''}
</p>
</div>
)}
{/* Capital */}
{checkResult.capital != null && checkResult.capital.value != null && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Уставный капитал</p>
<p className="text-sm font-bold text-slate-800">
{checkResult.capital.value.toLocaleString('ru-RU')}
{checkResult.capital.type && <span className="text-slate-500 font-normal ml-1">({checkResult.capital.type})</span>}
</p>
</div>
)}
{/* SMB */}
{checkResult.smb?.category && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Категория МСП</p>
<p className="text-sm text-slate-700">
{checkResult.smb.category === 'MICRO' ? 'Микропредприятие' :
checkResult.smb.category === 'SMALL' ? 'Малое' :
checkResult.smb.category === 'MEDIUM' ? 'Среднее' : checkResult.smb.category}
{checkResult.smb.issueDate && (
<span className="text-slate-500 text-xs ml-1">
(реестр с {new Date(checkResult.smb.issueDate).toLocaleDateString('ru-RU')})
</span>
)}
</p>
</div>
)}
{/* Licenses */}
{checkResult.licenses && checkResult.licenses.length > 0 && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Лицензии</p>
<ul className="text-sm text-slate-700 space-y-1">
{checkResult.licenses.map((lic, idx) => {
const validTo = lic.validTo ? new Date(lic.validTo).getTime() : null;
const expired = validTo != null && validTo < Date.now();
return (
<li key={idx} className={expired ? 'text-amber-600' : ''}>
{lic.number && <span className="font-medium">{lic.number}</span>}
{lic.validTo && (
<span className="ml-1">
{expired ? 'истекла' : 'до'} {new Date(lic.validTo).toLocaleDateString('ru-RU')}
</span>
)}
{lic.activities?.length ? `${lic.activities.join(', ')}` : ''}
</li>
);
})}
</ul>
</div>
)}
{/* Management disqualified */}
{checkResult.managementDisqualified && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl">
<p className="text-xs font-black text-red-600 uppercase mb-1">Руководитель дисквалифицирован</p>
<p className="text-sm text-red-700">Директор не имеет права заключать сделки</p>
</div>
)}
{/* Invalidity details */}
{(checkResult.addressInvalidity || (checkResult.foundersInvalidity && checkResult.foundersInvalidity.length > 0) || (checkResult.managersInvalidity && checkResult.managersInvalidity.length > 0)) && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-xs font-black text-amber-700 uppercase mb-1">Недостоверные сведения</p>
<ul className="text-xs text-amber-800 space-y-0.5">
{checkResult.addressInvalidity && <li> Адрес признан недостоверным (ФНС)</li>}
{checkResult.foundersInvalidity?.length ? <li> Сведения об учредителях недостоверны</li> : null}
{checkResult.managersInvalidity?.length ? <li> Сведения о руководителе недостоверны</li> : null}
</ul>
</div>
)}
</div>
</div>
)}
{/* Check History */}
<div className="space-y-4">
<div className="flex justify-between items-center px-1">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">История проверок</h3>
<button
onClick={loadHistory}
className="text-[10px] font-black text-primary-600 uppercase hover:underline"
>
Обновить
</button>
</div>
{loadingHistory ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-slate-400" />
</div>
) : checkedCounterparties.length === 0 ? (
<div className="bg-white p-8 rounded-xl border border-slate-200 text-center">
<p className="text-slate-400">История проверок пуста</p>
<p className="text-xs text-slate-500 mt-2">Проверьте контрагента, чтобы добавить запись в историю</p>
</div>
) : (
checkedCounterparties.map(cp => {
const isHighRisk = cp.riskLevel === 'high';
const isMediumRisk = cp.riskLevel === 'medium';
return (
<div key={cp.id} className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm flex flex-col md:flex-row justify-between gap-6 hover:shadow-md transition-all group">
<div className="flex items-center gap-4">
<div className={`p-4 rounded-2xl ${isHighRisk ? 'bg-red-50 text-red-600' : isMediumRisk ? 'bg-amber-50 text-amber-600' : 'bg-emerald-50 text-emerald-600'}`}>
{isHighRisk ? <ShieldAlert className="w-7 h-7" /> : isMediumRisk ? <AlertTriangle className="w-7 h-7" /> : <CheckCircle2 className="w-7 h-7" />}
</div>
<div>
<h4 className="font-black text-slate-800 text-base leading-tight">{cp.name}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">
ИНН: {cp.inn} Проверен: {cp.checkedDate ? new Date(cp.checkedDate).toLocaleDateString('ru-RU') : 'Не указано'}
</p>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-6 border-t md:border-t-0 border-slate-100 pt-4 md:pt-0">
<div className="text-right">
<p className={`text-[10px] font-black uppercase tracking-widest ${isHighRisk ? 'text-red-500' : isMediumRisk ? 'text-amber-500' : 'text-emerald-500'}`}>
{isHighRisk ? 'Высокий риск' : isMediumRisk ? 'Средний риск' : 'Благонадежен'}
</p>
<button
onClick={async () => {
try {
const response = await authFetch(`/api/legal/counterparties/${cp.id}`);
if (response.ok) {
const report = await response.json();
setSelectedReport(report);
setShowReportModal(true);
}
} catch (error) {
console.error('Error loading report:', error);
}
}}
className="text-[10px] font-black text-primary-600 uppercase mt-1 hover:underline"
>
Подробный отчет
</button>
</div>
<div className="w-10 h-10 rounded-full bg-slate-50 flex items-center justify-center text-slate-300 group-hover:text-primary-500 transition-colors cursor-pointer"
onClick={async () => {
try {
const response = await authFetch(`/api/legal/counterparties/${cp.id}`);
if (response.ok) {
const report = await response.json();
setSelectedReport(report);
setShowReportModal(true);
}
} catch (error) {
console.error('Error loading report:', error);
}
}}
>
<ChevronRight className="w-6 h-6"/>
</div>
</div>
</div>
);
}))}
</div>
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-2xl border border-blue-100">
<Info className="w-5 h-5 text-blue-500 flex-shrink-0" />
<p className="text-[11px] text-blue-700 leading-snug font-medium">
Система автоматически блокирует создание договоров с контрагентами, имеющими статус «Высокий риск» до ручного подтверждения Директором.
</p>
</div>
{/* Report Modal */}
{showReportModal && selectedReport && (
<ReportModal
report={selectedReport}
onClose={() => {
setShowReportModal(false);
setSelectedReport(null);
}}
/>
)}
</div>
);
};
// Report Modal Component
interface ReportModalProps {
report: any;
onClose: () => void;
}
const ReportModal: React.FC<ReportModalProps> = ({ report, onClose }) => {
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-4xl 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">Детальный отчет о проверке</h3>
<button onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-6">
{/* Header Info */}
<div className="bg-slate-50 rounded-xl p-4">
<h4 className="text-lg font-black text-slate-800 mb-2">{report.name}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-slate-500 text-xs">ИНН</p>
<p className="font-bold text-slate-800">{report.inn}</p>
</div>
{report.kpp && (
<div>
<p className="text-slate-500 text-xs">КПП</p>
<p className="font-bold text-slate-800">{report.kpp}</p>
</div>
)}
{report.ogrn && (
<div>
<p className="text-slate-500 text-xs">ОГРН</p>
<p className="font-bold text-slate-800">{report.ogrn}</p>
</div>
)}
<div>
<p className="text-slate-500 text-xs">Дата проверки</p>
<p className="font-bold text-slate-800">
{new Date(report.checkedDate).toLocaleString('ru-RU')}
</p>
</div>
</div>
</div>
{/* Risk Level */}
<div className={`p-4 rounded-xl border-2 ${
report.riskLevel === 'high' ? 'bg-red-50 border-red-200' :
report.riskLevel === 'medium' ? 'bg-amber-50 border-amber-200' :
'bg-emerald-50 border-emerald-200'
}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-black text-slate-800">Уровень риска:</span>
<span className={`text-sm font-black uppercase ${
report.riskLevel === 'high' ? 'text-red-600' :
report.riskLevel === 'medium' ? 'text-amber-600' :
'text-emerald-600'
}`}>
{report.riskLevel === 'high' ? 'Высокий риск' :
report.riskLevel === 'medium' ? 'Средний риск' :
'Благонадежен'}
</span>
</div>
{report.riskReasons && report.riskReasons.length > 0 && (
<ul className="text-xs text-slate-700 mt-2 space-y-1">
{report.riskReasons.map((reason: string, idx: number) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
<span>{reason}</span>
</li>
))}
</ul>
)}
</div>
{/* Status and Dates */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Статус</p>
<p className="text-sm font-bold text-slate-800">
{report.status === 'ACTIVE' ? 'Действующая' :
report.status === 'LIQUIDATED' ? 'Ликвидирована' :
report.status === 'LIQUIDATING' ? 'Ликвидируется' :
report.status === 'BANKRUPT' ? 'Банкрот' :
report.status === 'REORGANIZING' ? 'Реорганизуется' :
report.status}
</p>
</div>
{report.registrationDate && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Дата регистрации</p>
<p className="text-sm font-bold text-slate-800">
{new Date(report.registrationDate).toLocaleDateString('ru-RU')}
</p>
</div>
)}
{report.liquidationDate && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Дата ликвидации</p>
<p className="text-sm font-bold text-red-600">
{new Date(report.liquidationDate).toLocaleDateString('ru-RU')}
</p>
</div>
)}
</div>
{/* Address */}
{report.address && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Адрес</p>
<p className="text-sm text-slate-700">{report.address}</p>
</div>
)}
{/* Management */}
{report.managementName && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Руководитель</p>
<p className="text-sm text-slate-700">
{report.managementName}
{report.managementPost && `${report.managementPost}`}
</p>
</div>
)}
{/* OKVED */}
{report.okved && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Основной ОКВЭД</p>
<p className="text-sm text-slate-700">
{report.okved} {report.okveds?.find((o: any) => o.main)?.name || ''}
</p>
</div>
)}
{/* Finance */}
{report.finance && (
<div className="bg-slate-50 rounded-xl p-4">
<p className="text-xs font-black text-slate-500 uppercase mb-3">Финансовые показатели</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 text-sm">
{report.finance.revenue !== undefined && report.finance.revenue !== null && (
<div>
<p className="text-slate-500">Выручка ({report.finance.year})</p>
<p className="font-bold text-slate-800">
{report.finance.revenue.toLocaleString('ru-RU')}
</p>
</div>
)}
{report.finance.income !== undefined && report.finance.income !== null && (
<div>
<p className="text-slate-500">Доходы ({report.finance.year})</p>
<p className="font-bold text-slate-800">
{report.finance.income.toLocaleString('ru-RU')}
</p>
</div>
)}
{report.finance.expense !== undefined && report.finance.expense !== null && (
<div>
<p className="text-slate-500">Расходы ({report.finance.year})</p>
<p className="font-bold text-slate-800">
{report.finance.expense.toLocaleString('ru-RU')}
</p>
</div>
)}
{report.finance.debt !== undefined && report.finance.debt !== null && report.finance.debt > 0 && (
<div>
<p className="text-red-600">Недоимки</p>
<p className="font-bold text-red-600">
{report.finance.debt.toLocaleString('ru-RU')}
</p>
</div>
)}
{report.finance.penalty !== undefined && report.finance.penalty !== null && report.finance.penalty > 0 && (
<div>
<p className="text-red-600">Штрафы</p>
<p className="font-bold text-red-600">
{report.finance.penalty.toLocaleString('ru-RU')}
</p>
</div>
)}
{report.finance.taxSystem && (
<div>
<p className="text-slate-500">Система налогообложения</p>
<p className="font-bold text-slate-800">{report.finance.taxSystem}</p>
</div>
)}
</div>
</div>
)}
{/* Authorities */}
{report.authorities && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-2">Государственные органы</p>
<div className="space-y-2 text-sm">
{report.authorities.ftsRegistration && (
<div className="bg-slate-50 rounded-lg p-3">
<p className="font-bold text-slate-800">ИФНС регистрации</p>
<p className="text-slate-600">{report.authorities.ftsRegistration.name}</p>
{report.authorities.ftsRegistration.address && (
<p className="text-xs text-slate-500 mt-1">{report.authorities.ftsRegistration.address}</p>
)}
</div>
)}
{report.authorities.ftsReport && (
<div className="bg-slate-50 rounded-lg p-3">
<p className="font-bold text-slate-800">ИФНС отчётности</p>
<p className="text-slate-600">{report.authorities.ftsReport.name}</p>
</div>
)}
</div>
</div>
)}
{/* Phones and Emails */}
{(report.phones?.length > 0 || report.emails?.length > 0) && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-2">Контакты</p>
<div className="space-y-2 text-sm">
{report.phones?.map((phone: any, idx: number) => (
<div key={idx} className="flex items-center gap-2">
<Phone className="w-4 h-4 text-slate-400" />
<span className="text-slate-700">{phone.value || phone}</span>
</div>
))}
{report.emails?.map((email: any, idx: number) => (
<div key={idx} className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-400" />
<span className="text-slate-700">{email.value || email}</span>
</div>
))}
</div>
</div>
)}
{/* Employee Count */}
{report.employeeCount && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Среднесписочная численность</p>
<p className="text-sm font-bold text-slate-800">{report.employeeCount} человек</p>
</div>
)}
{/* Capital */}
{report.capital != null && report.capital.value != null && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Уставный капитал</p>
<p className="text-sm font-bold text-slate-800">
{report.capital.value.toLocaleString('ru-RU')}
{report.capital.type && <span className="text-slate-500 font-normal ml-1">({report.capital.type})</span>}
</p>
</div>
)}
{/* SMB */}
{report.smb?.category && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Категория МСП</p>
<p className="text-sm text-slate-700">
{report.smb.category === 'MICRO' ? 'Микропредприятие' :
report.smb.category === 'SMALL' ? 'Малое' :
report.smb.category === 'MEDIUM' ? 'Среднее' : report.smb.category}
{report.smb.issueDate && (
<span className="text-slate-500 text-xs ml-1">
(реестр с {new Date(report.smb.issueDate).toLocaleDateString('ru-RU')})
</span>
)}
</p>
</div>
)}
{/* Licenses */}
{report.licenses && report.licenses.length > 0 && (
<div className="bg-slate-50 rounded-xl p-4">
<p className="text-xs font-black text-slate-500 uppercase mb-2">Лицензии</p>
<ul className="text-sm text-slate-700 space-y-2">
{report.licenses.map((lic: { number?: string; validTo?: number; validFrom?: number; activities?: string[] }, idx: number) => {
const validTo = lic.validTo ? new Date(lic.validTo).getTime() : null;
const expired = validTo != null && validTo < Date.now();
return (
<li key={idx} className={expired ? 'text-amber-600' : ''}>
{lic.number && <span className="font-bold">{lic.number}</span>}
{lic.validFrom && (
<span className="ml-1 text-slate-500">с {new Date(lic.validFrom).toLocaleDateString('ru-RU')}</span>
)}
{lic.validTo && (
<span className="ml-1">
{expired ? 'истекла' : 'до'} {new Date(lic.validTo).toLocaleDateString('ru-RU')}
</span>
)}
{lic.activities?.length ? (
<p className="text-xs text-slate-600 mt-0.5">{lic.activities.join(', ')}</p>
) : null}
</li>
);
})}
</ul>
</div>
)}
{/* Management disqualified */}
{report.managementDisqualified && (
<div className="p-4 bg-red-50 border-2 border-red-200 rounded-xl">
<p className="text-xs font-black text-red-600 uppercase mb-1">Руководитель дисквалифицирован</p>
<p className="text-sm text-red-700">Директор не имеет права заключать сделки</p>
</div>
)}
{/* Invalidity details */}
{(report.addressInvalidity || (report.foundersInvalidity && report.foundersInvalidity.length > 0) || (report.managersInvalidity && report.managersInvalidity.length > 0)) && (
<div className="p-4 bg-amber-50 border-2 border-amber-200 rounded-xl">
<p className="text-xs font-black text-amber-700 uppercase mb-2">Недостоверные сведения</p>
<ul className="text-sm text-amber-800 space-y-1">
{report.addressInvalidity && <li> Адрес признан недостоверным (ФНС)</li>}
{report.foundersInvalidity?.length ? <li> Сведения об учредителях недостоверны</li> : null}
{report.managersInvalidity?.length ? <li> Сведения о руководителе недостоверны</li> : null}
</ul>
</div>
)}
{/* Notes */}
{report.notes && (
<div>
<p className="text-xs font-black text-slate-500 uppercase mb-1">Примечания</p>
<p className="text-sm text-slate-700 bg-slate-50 p-3 rounded-lg">{report.notes}</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,584 @@
import React, { useState, useEffect } from 'react';
import { LegalContract, ContractStatus } from '../../types';
import { Search, FileText, FileSignature, CheckCircle2, Clock, ChevronRight, Archive, Inbox, Plus, X, Loader2, Paperclip, History, ExternalLink } from 'lucide-react';
import { authFetch } from '../../services/apiClient';
const STATUS_OPTIONS: { value: ContractStatus; label: string }[] = [
{ value: 'draft', label: 'Черновик' },
{ value: 'finance_approval', label: 'Согл. Фин.' },
{ value: 'counterparty_approval', label: 'Согл. Контр.' },
{ value: 'signing', label: 'На подписи' },
{ value: 'active', label: 'Подписан' },
{ value: 'archived', label: 'Архив' },
];
const STATUS_MAP: Record<ContractStatus, { label: string, color: string, bg: string }> = {
draft: { label: 'Черновик', color: 'text-slate-600', bg: 'bg-slate-100' },
finance_approval: { label: 'Согл. Фин.', color: 'text-amber-600', bg: 'bg-amber-100' },
counterparty_approval: { label: 'Согл. Контр.', color: 'text-blue-600', bg: 'bg-blue-100' },
signing: { label: 'На подписи', color: 'text-purple-600', bg: 'bg-purple-100' },
active: { label: 'Подписан', color: 'text-emerald-600', bg: 'bg-emerald-100' },
archived: { label: 'Архив', color: 'text-slate-400', bg: 'bg-slate-50' },
};
export const ContractsRegistry: React.FC = () => {
const [viewMode, setViewMode] = useState<'active' | 'archive'>('active');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [contracts, setContracts] = useState<LegalContract[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingContract, setEditingContract] = useState<LegalContract | null>(null);
useEffect(() => {
loadContracts();
}, [viewMode, statusFilter]);
const loadContracts = async () => {
try {
setLoading(true);
let url = `/api/legal/contracts?viewMode=${viewMode}&search=${encodeURIComponent(search)}`;
if (statusFilter && statusFilter !== 'all') url += `&status=${encodeURIComponent(statusFilter)}`;
const response = await authFetch(url);
if (response.ok) {
const data = await response.json();
setContracts(data);
}
} catch (error) {
console.error('Error loading contracts:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
const timeoutId = setTimeout(() => {
if (search !== undefined) {
loadContracts();
}
}, 300);
return () => clearTimeout(timeoutId);
}, [search]);
const uploadsBase = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/api\/?$/, '') || '';
const handleCreate = () => {
setEditingContract(null);
setShowModal(true);
};
const handleEdit = (contract: LegalContract) => {
setEditingContract(contract);
setShowModal(true);
};
const handleSave = async (contractData: Partial<LegalContract>) => {
try {
if (editingContract) {
// Обновление
const response = await authFetch(`/api/legal/contracts/${editingContract.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contractData)
});
if (response.ok) {
await loadContracts();
setShowModal(false);
setEditingContract(null);
}
} else {
// Создание
const response = await authFetch('/api/legal/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contractData)
});
if (response.ok) {
await loadContracts();
setShowModal(false);
}
}
} catch (error) {
console.error('Error saving contract:', error);
alert('Ошибка при сохранении договора');
}
};
const filtered = contracts.filter(c => {
const searchMatch = !search ||
c.counterparty.toLowerCase().includes(search.toLowerCase()) ||
c.number.toLowerCase().includes(search.toLowerCase());
return searchMatch;
});
return (
<div className="space-y-4 animate-fade-in">
{/* Top Toggle & Search & Create Button */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex bg-slate-200/50 p-1 rounded-2xl w-fit">
<button
onClick={() => setViewMode('active')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase transition-all ${viewMode === 'active' ? 'bg-white shadow text-primary-600' : 'text-slate-500'}`}
>
<Inbox className="w-4 h-4"/> Реестр
</button>
<button
onClick={() => setViewMode('archive')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase transition-all ${viewMode === 'archive' ? 'bg-white shadow text-primary-600' : 'text-slate-500'}`}
>
<Archive className="w-4 h-4"/> Архив
</button>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-xl text-xs font-bold bg-white text-slate-700 outline-none focus:ring-2 focus:ring-primary-500 w-fit"
>
<option value="all">Все статусы</option>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по ИНН, названию, номеру..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 transition-all shadow-sm"
/>
</div>
{viewMode === 'active' && (
<button
onClick={handleCreate}
className="bg-primary-600 text-white px-4 py-3 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>
)}
</div>
{/* List */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : (
<div className="space-y-3">
{filtered.map(contract => {
const status = STATUS_MAP[contract.status];
return (
<div
key={contract.id}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all cursor-pointer group"
onClick={() => handleEdit(contract)}
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className={`p-3 rounded-2xl ${status.bg} ${status.color}`}>
<FileSignature className="w-6 h-6"/>
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-[10px] font-black px-2 py-0.5 rounded uppercase tracking-wider ${status.bg} ${status.color}`}>
{status.label}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase"> {contract.number}</span>
</div>
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">{contract.counterparty}</h4>
</div>
</div>
<div className="text-right">
<p className="text-sm font-black text-slate-900">{contract.amount.toLocaleString()} </p>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1 italic">{contract.type}</p>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-100">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-1.5 text-xs text-slate-500 font-medium">
<Clock className="w-3.5 h-3.5"/> до {contract.endDate}
</div>
{contract.autoProlongation && (
<div className="flex items-center gap-1 text-[10px] font-black text-emerald-600 uppercase">
<CheckCircle2 className="w-3 h-3"/> Автопролонгация
</div>
)}
{contract.contractFileUrl && (
<a
href={uploadsBase ? `${uploadsBase}${contract.contractFileUrl}` : contract.contractFileUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-[10px] font-black text-primary-600 uppercase hover:text-primary-700"
>
<Paperclip className="w-3.5 h-3.5"/> Документ
</a>
)}
</div>
<div className="flex items-center gap-2 text-[10px] font-black text-primary-600 uppercase group-hover:translate-x-1 transition-transform">
Открыть <ChevronRight className="w-4 h-4"/>
</div>
</div>
</div>
);
})}
{filtered.length === 0 && (
<div className="py-20 text-center text-slate-400">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p className="font-bold uppercase tracking-widest text-xs">Договоры не найдены</p>
{viewMode === 'active' && (
<button
onClick={handleCreate}
className="mt-4 bg-primary-600 text-white px-6 py-2 rounded-xl text-xs font-black uppercase"
>
Создать первый договор
</button>
)}
</div>
)}
</div>
)}
{/* Contract Modal */}
{showModal && (
<ContractModal
contract={editingContract}
onClose={() => {
setShowModal(false);
setEditingContract(null);
}}
onSave={handleSave}
/>
)}
</div>
);
};
interface ContractModalProps {
contract: LegalContract | null;
onClose: () => void;
onSave: (data: Partial<LegalContract>) => Promise<void>;
}
interface ContractHistoryEntry {
fromStatus: string | null;
toStatus: string;
changedBy: string;
createdAt: string;
}
const ContractModal: React.FC<ContractModalProps> = ({ contract, onClose, onSave }) => {
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [history, setHistory] = useState<ContractHistoryEntry[]>([]);
const [formData, setFormData] = useState({
number: contract?.number || '',
type: contract?.type || '',
counterparty: contract?.counterparty || '',
counterpartyInn: contract?.counterpartyInn ?? '',
amount: contract?.amount || 0,
startDate: contract?.startDate || '',
endDate: contract?.endDate || '',
autoProlongation: contract?.autoProlongation ?? false,
manager: contract?.manager || '',
notes: contract?.notes ?? '',
status: (contract?.status || 'draft') as ContractStatus,
hasDisagreements: contract?.hasDisagreements ?? false,
contractFileUrl: contract?.contractFileUrl ?? '',
});
useEffect(() => {
setFormData({
number: contract?.number || '',
type: contract?.type || '',
counterparty: contract?.counterparty || '',
counterpartyInn: contract?.counterpartyInn ?? '',
amount: contract?.amount || 0,
startDate: contract?.startDate || '',
endDate: contract?.endDate || '',
autoProlongation: contract?.autoProlongation ?? false,
manager: contract?.manager || '',
notes: contract?.notes ?? '',
status: (contract?.status || 'draft') as ContractStatus,
hasDisagreements: contract?.hasDisagreements ?? false,
contractFileUrl: contract?.contractFileUrl ?? '',
});
if (contract?.id) {
authFetch(`/api/legal/contracts/${contract.id}/history`)
.then((r) => r.ok ? r.json() : [])
.then(setHistory)
.catch(() => setHistory([]));
} else {
setHistory([]);
}
}, [contract?.id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await onSave(formData);
} finally {
setLoading(false);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await authFetch('/api/legal/contracts/upload', {
method: 'POST',
body: fd,
});
if (res.ok) {
const data = await res.json();
setFormData((prev) => ({ ...prev, contractFileUrl: data.fileUrl || '' }));
}
} catch (err) {
console.error('Upload error:', err);
alert('Ошибка загрузки файла');
} finally {
setUploading(false);
e.target.value = '';
}
};
const uploadsBase = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/api\/?$/, '') || '';
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">
{contract ? 'Редактировать договор' : 'Создать договор'}
</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 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.number}
onChange={(e) => setFormData({ ...formData, number: 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"
required
/>
</div>
<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 })}
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="">Выберите тип</option>
<option value="Поставка">Поставка</option>
<option value="Услуги">Услуги</option>
<option value="Подряд">Подряд</option>
<option value="Аренда">Аренда</option>
<option value="Другое">Другое</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Контрагент *</label>
<input
type="text"
value={formData.counterparty}
onChange={(e) => setFormData({ ...formData, counterparty: 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"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">ИНН контрагента</label>
<input
type="text"
value={formData.counterpartyInn}
onChange={(e) => setFormData({ ...formData, counterpartyInn: e.target.value.replace(/\D/g, '') })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
maxLength={12}
placeholder="10 или 12 цифр"
/>
</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="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>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Менеджер *</label>
<input
type="text"
value={formData.manager}
onChange={(e) => setFormData({ ...formData, manager: 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"
required
/>
</div>
</div>
{contract && (
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Статус</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ContractStatus })}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</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.startDate}
onChange={(e) => setFormData({ ...formData, startDate: 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"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Дата окончания *</label>
<input
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: 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"
required
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoProlongation"
checked={formData.autoProlongation}
onChange={(e) => setFormData({ ...formData, autoProlongation: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="autoProlongation" className="text-sm text-slate-700">
Автоматическая пролонгация
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasDisagreements"
checked={formData.hasDisagreements}
onChange={(e) => setFormData({ ...formData, hasDisagreements: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="hasDisagreements" className="text-sm text-slate-700">
Есть разногласия
</label>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Файл договора</label>
{formData.contractFileUrl ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={uploadsBase ? `${uploadsBase}${formData.contractFileUrl}` : formData.contractFileUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-primary-600 hover:text-primary-700 text-sm font-medium"
>
<ExternalLink className="w-4 h-4" /> Открыть документ
</a>
<label className="inline-flex items-center gap-1.5 text-slate-600 text-sm cursor-pointer">
<Paperclip className="w-4 h-4" />
{uploading ? 'Загрузка...' : 'Заменить файл'}
<input type="file" className="hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.txt,.zip,.rar" onChange={handleFileChange} disabled={uploading} />
</label>
</div>
) : (
<label className="inline-flex items-center gap-2 px-3 py-2 border border-slate-200 rounded-xl text-sm text-slate-600 cursor-pointer hover:bg-slate-50">
<Paperclip className="w-4 h-4" />
{uploading ? 'Загрузка...' : 'Загрузить файл'}
<input type="file" className="hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.txt,.zip,.rar" onChange={handleFileChange} disabled={uploading} />
</label>
)}
</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>
{contract && history.length > 0 && (
<div className="border border-slate-200 rounded-xl p-4 bg-slate-50">
<div className="flex items-center gap-2 mb-2 text-xs font-black text-slate-700 uppercase">
<History className="w-4 h-4" /> История статусов
</div>
<ul className="space-y-1.5 text-sm">
{history.map((h, i) => (
<li key={i} className="flex flex-wrap items-center gap-1 text-slate-600">
<span className="text-slate-400">{new Date(h.createdAt).toLocaleString('ru')}</span>
<span>{h.fromStatus ? STATUS_MAP[h.fromStatus as ContractStatus]?.label || h.fromStatus : '—'}</span>
<span></span>
<span className="font-medium">{STATUS_MAP[h.toStatus as ContractStatus]?.label || h.toStatus}</span>
<span className="text-slate-500">({h.changedBy})</span>
</li>
))}
</ul>
</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>
);
};

595
components/legal/CourtCases.tsx Executable file
View File

@@ -0,0 +1,595 @@
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>
);
};

View File

@@ -0,0 +1,915 @@
import React, { useState, useEffect } from 'react';
import { LegalCourtCase, FsspStage } from '../../types';
import {
HandCoins,
Gavel,
ShieldAlert,
CheckCircle2,
ArrowRight,
Search,
Filter,
TrendingUp,
Landmark,
MessageSquare,
AlertCircle,
ChevronRight,
Zap,
CircleDollarSign,
Receipt,
Clock,
AlertTriangle,
Plus,
Loader2,
X,
FileText,
History,
User,
Calendar,
DollarSign,
Edit,
Save
} from 'lucide-react';
import { authFetch } from '../../services/apiClient';
import { CaseDetailsModal } from './CaseDetailsModal';
interface DebtRecoveryPipelineProps {
onNavigateToPreTrial?: () => void;
}
interface PreTrialWorkItem {
id: number;
status: string;
debtor?: { debtorName?: string; address?: string; debt_amount?: number; apartment?: string };
debtorId?: number;
}
export const DebtRecoveryPipeline: React.FC<DebtRecoveryPipelineProps> = ({ onNavigateToPreTrial }) => {
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<'pipeline' | 'fssp_active' | 'pipeline_board'>('pipeline');
const [debtCases, setDebtCases] = useState<LegalCourtCase[]>([]);
const [preTrialWorks, setPreTrialWorks] = useState<PreTrialWorkItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCase, setSelectedCase] = useState<LegalCourtCase | null>(null);
const [showCaseModal, setShowCaseModal] = useState(false);
const [showBailiffModal, setShowBailiffModal] = useState(false);
const loadCases = async () => {
try {
setLoading(true);
const query = new URLSearchParams({ type: 'debt_recovery' });
if (search.trim()) query.set('search', search.trim());
const response = await authFetch(`/api/legal/court-cases?${query.toString()}`);
if (response.ok) {
const data = await response.json();
// Фильтруем дела типа debt_recovery или содержащие "взыскание" в предмете
const filtered = Array.isArray(data)
? data.filter((c: LegalCourtCase) =>
c.type === 'debt_recovery' ||
(c.subject && c.subject.toLowerCase().includes('взыскание')) ||
(c.subject && c.subject.toLowerCase().includes('задолженност'))
)
: [];
setDebtCases(filtered);
} else {
setDebtCases([]);
}
} catch (error) {
console.error('Error loading court cases:', error);
setDebtCases([]);
} finally {
setLoading(false);
}
};
const loadPreTrialWorks = async () => {
try {
const response = await authFetch('/api/legal/pre-trial-work');
if (response.ok) {
const data = await response.json();
const list = Array.isArray(data) ? data : [];
const notTransferred = list.filter((w: PreTrialWorkItem) => w.status !== 'transferred_to_court' && w.status !== 'resolved');
setPreTrialWorks(notTransferred);
} else {
setPreTrialWorks([]);
}
} catch (e) {
console.error('Error loading pre-trial work:', e);
setPreTrialWorks([]);
}
};
useEffect(() => {
if (search === '') {
loadCases();
return;
}
const t = setTimeout(() => loadCases(), 300);
return () => clearTimeout(t);
}, [search]);
useEffect(() => {
if (viewMode === 'pipeline_board') {
loadPreTrialWorks();
}
}, [viewMode]);
const totalClaimed = debtCases.length > 0 ? debtCases.reduce((sum, c) => sum + (c.amount || 0), 0) : 0;
const totalRecovered = debtCases.length > 0 ? debtCases.reduce((sum, c) => sum + (c.recoveredAmount || 0), 0) : 0;
const moneyAtBailiffs = debtCases.length > 0 ? debtCases.reduce((sum, c) => sum + (c.amountAtBailiffs || 0), 0) : 0;
const inTransit = totalClaimed > 0 ? totalClaimed - totalRecovered - moneyAtBailiffs : 0;
return (
<div className="space-y-6 animate-fade-in">
{/* Financial Dashboard for Legal - ENHANCED */}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<HandCoins className="absolute -top-4 -right-4 w-48 h-48 opacity-10 rotate-12 text-emerald-400" />
<div className="relative z-10">
<div className="flex justify-between items-start mb-10">
<div>
<h3 className="text-2xl font-black">Возврат задолженности</h3>
<p className="text-slate-400 text-xs font-bold uppercase tracking-widest mt-1">Прямой приход денежных средств</p>
</div>
<div className="flex gap-4">
<div className="text-right">
<p className="text-[10px] font-black text-emerald-400 uppercase tracking-widest">Взыскано (УК)</p>
<p className="text-2xl font-black text-emerald-400">{totalRecovered.toLocaleString()} </p>
</div>
<div className="text-right border-l border-white/10 pl-4">
<p className="text-[10px] font-black text-amber-400 uppercase tracking-widest">У приставов</p>
<p className="text-2xl font-black text-amber-400">{moneyAtBailiffs.toLocaleString()} </p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-tighter mb-1">Сумма в работе</p>
<p className="text-xl font-black text-white">{totalClaimed.toLocaleString()} </p>
</div>
<div className="flex flex-col justify-end">
<div className="flex justify-between text-[9px] font-black uppercase text-slate-500 mb-1">
<span>Прогресс сбора</span>
<span>{Math.round(((totalRecovered + moneyAtBailiffs)/totalClaimed)*100)}%</span>
</div>
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden flex">
<div className="h-full bg-emerald-500" style={{ width: `${(totalRecovered/totalClaimed)*100}%` }} />
<div className="h-full bg-amber-500" style={{ width: `${(moneyAtBailiffs/totalClaimed)*100}%` }} />
</div>
</div>
<div className="text-right">
<p className="text-slate-400 text-[10px] font-black uppercase tracking-tighter mb-1">Остаток (Листы/Суд)</p>
<p className="text-xl font-black text-slate-300">{inTransit.toLocaleString()} </p>
</div>
</div>
</div>
</div>
{/* View Toggle */}
<div className="flex p-1 bg-slate-200/50 rounded-2xl w-full md:w-fit gap-1">
<button
onClick={() => setViewMode('pipeline')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${viewMode === 'pipeline' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500'}`}
>
Все дела
</button>
<button
onClick={() => setViewMode('pipeline_board')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${viewMode === 'pipeline_board' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500'}`}
>
Пайплайн
</button>
<button
onClick={() => setViewMode('fssp_active')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${viewMode === 'fssp_active' ? 'bg-white text-amber-600 shadow-sm' : 'text-slate-500'}`}
>
Контроль приставов
</button>
</div>
{viewMode === 'pipeline_board' ? (
<div className="overflow-x-auto pb-4">
<div className="flex gap-4 min-w-max">
<PipelineColumn title="Досудебная" count={preTrialWorks.length} color="bg-slate-100 border-slate-200">
{preTrialWorks.map(w => (
<div key={w.id} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<p className="font-bold text-slate-800 text-sm truncate">{w.debtor?.debtorName || 'Должник'}</p>
<p className="text-[10px] text-slate-500 uppercase mt-0.5 truncate">{w.debtor?.address}</p>
<p className="text-xs font-black text-slate-700 mt-2">{(w.debtor?.debt_amount || 0).toLocaleString()} </p>
<button type="button" onClick={() => onNavigateToPreTrial?.()} className="mt-2 w-full py-1.5 text-[10px] font-black uppercase bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200">В досудебную</button>
</div>
))}
</PipelineColumn>
<PipelineColumn title="В суде" count={debtCases.filter(c => c.status === 'pre_trial' || c.status === 'litigation').length} color="bg-blue-50 border-blue-200">
{debtCases.filter(c => c.status === 'pre_trial' || c.status === 'litigation').map(c => (
<div key={c.id} onClick={() => { setSelectedCase(c); setShowCaseModal(true); }} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm cursor-pointer hover:border-primary-300">
<p className="font-bold text-slate-800 text-sm truncate">{c.debtorName}</p>
<p className="text-[10px] text-slate-500 uppercase mt-0.5 truncate">{c.address}</p>
<p className="text-xs font-black text-slate-700 mt-2">{c.amount.toLocaleString()} </p>
</div>
))}
</PipelineColumn>
<PipelineColumn title="Решение получено" count={debtCases.filter(c => c.status === 'decision_received').length} color="bg-indigo-50 border-indigo-200">
{debtCases.filter(c => c.status === 'decision_received').map(c => (
<div key={c.id} onClick={() => { setSelectedCase(c); setShowCaseModal(true); }} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm cursor-pointer hover:border-primary-300">
<p className="font-bold text-slate-800 text-sm truncate">{c.debtorName}</p>
<p className="text-[10px] text-slate-500 uppercase mt-0.5 truncate">{c.address}</p>
<p className="text-xs font-black text-slate-700 mt-2">{c.amount.toLocaleString()} </p>
</div>
))}
</PipelineColumn>
<PipelineColumn title="У приставов" count={debtCases.filter(c => c.status === 'enforcement').length} color="bg-amber-50 border-amber-200">
{debtCases.filter(c => c.status === 'enforcement').map(c => (
<div key={c.id} onClick={() => { setSelectedCase(c); setShowBailiffModal(true); }} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm cursor-pointer hover:border-amber-300">
<p className="font-bold text-slate-800 text-sm truncate">{c.debtorName}</p>
<p className="text-[10px] text-slate-500 uppercase mt-0.5 truncate">{c.address}</p>
<p className="text-xs font-black text-amber-600 mt-2">{(c.amountAtBailiffs || 0).toLocaleString()} у ФССП</p>
</div>
))}
</PipelineColumn>
<PipelineColumn title="Закрыто" count={debtCases.filter(c => c.status === 'closed').length} color="bg-emerald-50 border-emerald-200">
{debtCases.filter(c => c.status === 'closed').map(c => (
<div key={c.id} onClick={() => { setSelectedCase(c); setShowCaseModal(true); }} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm cursor-pointer hover:border-emerald-300">
<p className="font-bold text-slate-800 text-sm truncate">{c.debtorName}</p>
<p className="text-[10px] text-slate-500 uppercase mt-0.5 truncate">{c.address}</p>
<p className="text-xs font-black text-emerald-600 mt-2">Взыскано {(c.recoveredAmount || 0).toLocaleString()} </p>
</div>
))}
</PipelineColumn>
</div>
</div>
) : viewMode === 'pipeline' ? (
<div className="space-y-4">
<div className="flex gap-4 px-1">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск должника или квартиры..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
/>
</div>
<button className="p-3 bg-white border border-slate-200 rounded-2xl text-slate-500"><Filter className="w-5 h-5"/></button>
<button
onClick={() => onNavigateToPreTrial ? onNavigateToPreTrial() : (window.location.hash = '#preTrial')}
className="bg-primary-600 text-white px-4 py-3 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>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-slate-400">Загрузка...</div>
</div>
) : (
<div className="space-y-3">
{debtCases.map(caseItem => (
<DebtCaseCard
key={caseItem.id}
item={caseItem}
onClick={() => {
setSelectedCase(caseItem);
setShowCaseModal(true);
}}
/>
))}
{debtCases.length === 0 && (
<div className="py-20 text-center text-slate-400">
<p className="font-bold uppercase tracking-widest text-xs">Дела не найдены</p>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
{/* FSSP Detailed View */}
<div className="bg-amber-50 border border-amber-100 p-4 rounded-2xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5"/>
<div>
<p className="text-xs font-black text-amber-800 uppercase tracking-tight">Зависло у приставов: {moneyAtBailiffs.toLocaleString()} </p>
<p className="text-[10px] text-amber-700 mt-1">Деньги взысканы ФССП, но не поступили на счет УК. Требуется сверка с депозитом отдела.</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-slate-400">Загрузка...</div>
</div>
) : (
<div className="space-y-3">
{debtCases.filter(c => c.status === 'enforcement').map(caseItem => (
<BailiffActionCard
key={caseItem.id}
item={caseItem}
onClick={() => {
setSelectedCase(caseItem);
setShowBailiffModal(true);
}}
/>
))}
{debtCases.filter(c => c.status === 'enforcement').length === 0 && (
<div className="py-20 text-center text-slate-400">
<p className="font-bold uppercase tracking-widest text-xs">Нет дел у приставов</p>
</div>
)}
</div>
)}
</div>
)}
{/* Case Details Modal */}
{showCaseModal && selectedCase && (
<CaseDetailsModal
courtCase={selectedCase}
onClose={() => {
setShowCaseModal(false);
setSelectedCase(null);
}}
onUpdate={(updatedCase) => {
setDebtCases(debtCases.map(c => c.id === updatedCase.id ? updatedCase : c));
loadCases();
}}
/>
)}
{/* Bailiff Interaction Modal */}
{showBailiffModal && selectedCase && (
<BailiffInteractionModalComponent
courtCase={selectedCase}
onClose={() => {
setShowBailiffModal(false);
setSelectedCase(null);
}}
onUpdate={(updatedCase) => {
setDebtCases(debtCases.map(c => c.id === updatedCase.id ? updatedCase : c));
loadCases();
}}
/>
)}
</div>
);
};
const PipelineColumn: React.FC<{ title: string; count: number; color: string; children: React.ReactNode }> = ({ title, count, color, children }) => (
<div className={`w-64 flex-shrink-0 rounded-2xl border-2 p-4 ${color}`}>
<div className="flex justify-between items-center mb-3">
<h4 className="text-xs font-black text-slate-700 uppercase tracking-wider">{title}</h4>
<span className="text-[10px] font-black text-slate-500 bg-white/80 px-2 py-0.5 rounded-full">{count}</span>
</div>
<div className="space-y-2 max-h-[70vh] overflow-y-auto">{children}</div>
</div>
);
const DebtCaseCard: React.FC<{ item: LegalCourtCase; onClick: () => void }> = ({ item, onClick }) => {
const isEnforcement = item.status === 'enforcement';
const isLitigation = item.status === 'litigation';
return (
<div
onClick={onClick}
className="bg-white p-5 rounded-[2.5rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all flex flex-col md:flex-row gap-6 group cursor-pointer"
>
<div className="flex items-center gap-4 flex-1">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${isEnforcement ? 'bg-amber-50 text-amber-600' : isLitigation ? 'bg-blue-50 text-blue-600' : 'bg-slate-50 text-slate-400'}`}>
{isEnforcement ? <HandCoins className="w-7 h-7"/> : <Gavel className="w-7 h-7"/>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter ${isEnforcement ? 'bg-amber-100 text-amber-600' : isLitigation ? 'bg-blue-100 text-blue-600' : 'bg-slate-100 text-slate-600'}`}>
{item.status === 'pre_trial' ? 'Претензия' : item.status === 'litigation' ? 'В суде' : item.status === 'enforcement' ? 'У приставов' : 'Закрыто'}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase">{item.caseNumber}</span>
</div>
<h4 className="font-black text-slate-800 text-base group-hover:text-primary-600 transition-colors truncate">{item.debtorName}</h4>
<p className="text-[10px] text-slate-500 font-bold uppercase mt-1 flex items-center gap-1">
<Landmark className="w-3 h-3"/> {item.address}
</p>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-8 border-t md:border-t-0 border-slate-50 pt-4 md:pt-0">
<div className="text-right">
<p className="text-[9px] text-slate-400 font-black uppercase mb-1">Сумма долга</p>
<p className="text-sm font-black text-slate-900">{item.amount.toLocaleString()} </p>
{item.recoveredAmount && item.recoveredAmount > 0 && (
<p className="text-[10px] font-black text-emerald-600 mt-0.5 flex items-center justify-end gap-1">
<TrendingUp className="w-3 h-3"/> {item.recoveredAmount.toLocaleString()}
</p>
)}
</div>
<div className="text-right min-w-[120px]">
{isEnforcement ? (
<div>
<p className="text-[9px] text-amber-600 font-black uppercase mb-1">Статус ФССП</p>
<p className="text-[11px] font-bold text-slate-700">{item.fsspStatus}</p>
</div>
) : (
<div>
<p className="text-[9px] text-slate-400 font-black uppercase mb-1">Заседание</p>
<p className="text-[11px] font-bold text-slate-700">{item.nextHearingDate || 'Не назначено'}</p>
</div>
)}
</div>
<div className="flex gap-1">
<button
onClick={(e) => e.stopPropagation()}
className="p-2.5 bg-slate-50 text-slate-400 rounded-xl hover:text-primary-600 hover:bg-primary-50 transition-colors"
>
<MessageSquare className="w-5 h-5"/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="p-2.5 bg-slate-50 text-slate-400 rounded-xl hover:text-primary-600 transition-colors"
>
<ChevronRight className="w-5 h-5"/>
</button>
</div>
</div>
</div>
);
};
const FSSP_STAGE_ORDER: FsspStage[] = ['writ_submitted', 'ip_initiated', 'bank_requests', 'money_on_deposit', 'transferred_to_uk'];
const FSSP_STAGE_LABELS: Record<FsspStage, string> = {
writ_submitted: 'ИЛ предъявлен',
ip_initiated: 'ИП возбуждено',
bank_requests: 'Запросы в банки',
money_on_deposit: 'Деньги на депозите',
transferred_to_uk: 'Перевод в УК'
};
const BailiffActionCard: React.FC<{ item: LegalCourtCase; onClick: () => void }> = ({ item, onClick }) => {
const lastAction = item.fsspLastActionDate ? new Date(item.fsspLastActionDate) : new Date();
const daysSinceLastAction = Math.floor((new Date().getTime() - lastAction.getTime()) / (1000 * 3600 * 24));
const isStuck = daysSinceLastAction > 20;
const currentStageIndex = item.fsspStage ? FSSP_STAGE_ORDER.indexOf(item.fsspStage) : -1;
return (
<div
onClick={onClick}
className="bg-white p-6 rounded-[2.5rem] border border-slate-200 shadow-sm hover:border-amber-400 transition-all group cursor-pointer"
>
<div className="flex flex-col md:flex-row justify-between gap-6">
<div className="flex items-center gap-4 flex-1">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${isStuck ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-600'}`}>
<CircleDollarSign className="w-8 h-8"/>
</div>
<div>
<h4 className="font-black text-slate-800 text-base">{item.debtorName}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter mt-0.5">{item.address}</p>
<div className="flex items-center gap-3 mt-3 flex-wrap">
{item.enforcementNumber && (
<div className="flex items-center gap-1 text-[10px] font-black text-slate-600 uppercase bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
ИП: {item.enforcementNumber}
</div>
)}
<div className="flex items-center gap-1 text-[10px] font-black text-slate-500 uppercase bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
<Landmark className="w-3 h-3"/> Пристав: {item.bailiffName || '—'}
</div>
<div className={`flex items-center gap-1 text-[10px] font-black uppercase px-2 py-0.5 rounded border ${isStuck ? 'bg-red-50 text-red-600 border-red-100' : 'bg-emerald-50 text-emerald-600 border-emerald-100'}`}>
<Clock className="w-3 h-3"/> {daysSinceLastAction} дн. без действий
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-10">
<div className="text-right">
<p className="text-[9px] text-slate-400 font-black uppercase mb-1">Сумма у ФССП</p>
<p className="text-lg font-black text-amber-600">{item.amountAtBailiffs?.toLocaleString()} </p>
<p className="text-[9px] text-slate-400 mt-1 font-bold">Из {item.amount.toLocaleString()} присужденных</p>
</div>
<div className="flex flex-col gap-2">
<button
onClick={(e) => { e.stopPropagation(); onClick(); }}
className="bg-amber-500 text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-amber-500/20 active:scale-95 transition-all flex items-center gap-2"
>
<Receipt className="w-3.5 h-3.5"/> Ходатайство
</button>
<button
onClick={(e) => { e.stopPropagation(); onClick(); }}
className="bg-slate-900 text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest active:scale-95 transition-all flex items-center gap-2"
>
<CheckCircle2 className="w-3.5 h-3.5"/> Сверка прихода
</button>
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-slate-50 flex items-center gap-4">
<span className="text-[8px] font-black text-slate-400 uppercase tracking-widest shrink-0">Цепочка взыскания:</span>
<div className="flex items-center gap-1 flex-1 min-w-0">
{FSSP_STAGE_ORDER.map((stage, idx) => {
const done = currentStageIndex > idx || (currentStageIndex === idx && stage === 'transferred_to_uk');
const current = currentStageIndex === idx && stage !== 'transferred_to_uk';
return (
<div
key={stage}
className={`h-1.5 rounded-full flex-1 min-w-0 ${done ? 'bg-emerald-500' : current ? 'bg-amber-400 animate-pulse' : 'bg-slate-100'}`}
title={FSSP_STAGE_LABELS[stage]}
/>
);
})}
</div>
</div>
</div>
);
};
// Bailiff Interaction Modal Component (inline version)
interface BailiffInteractionModalComponentProps {
courtCase: LegalCourtCase;
onClose: () => void;
onUpdate: (updatedCase: LegalCourtCase) => void;
}
const BailiffInteractionModalComponent: React.FC<BailiffInteractionModalComponentProps> = ({ courtCase, onUpdate, onClose }) => {
const [loading, setLoading] = useState(false);
const [comments, setComments] = useState<any[]>([]);
const [newComment, setNewComment] = useState('');
const [commentAuthor, setCommentAuthor] = useState('');
const [formData, setFormData] = useState({
fsspStatus: courtCase.fsspStatus || '',
bailiffName: courtCase.bailiffName || '',
fsspLastActionDate: courtCase.fsspLastActionDate || '',
amountAtBailiffs: courtCase.amountAtBailiffs || 0,
recoveredAmount: courtCase.recoveredAmount || 0,
enforcementNumber: courtCase.enforcementNumber || '',
enforcementStartDate: courtCase.enforcementStartDate || '',
fsspStage: courtCase.fsspStage || ''
});
const [newPetition, setNewPetition] = useState({
type: 'request',
text: '',
date: new Date().toISOString().split('T')[0]
});
useEffect(() => {
loadComments();
}, [courtCase.id]);
const loadComments = async () => {
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/comments`);
if (response.ok) {
const data = await response.json();
// Фильтруем комментарии, связанные с приставами
const bailiffComments = (data || []).filter((c: any) =>
c.comment.toLowerCase().includes('ходатайство') ||
c.comment.toLowerCase().includes('пристав') ||
c.comment.toLowerCase().includes('фссп')
);
setComments(bailiffComments);
}
} catch (error) {
console.error('Error loading comments:', error);
}
};
const handleSave = async () => {
setLoading(true);
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
changedBy: commentAuthor || 'System'
})
});
if (response.ok) {
const updated = await response.json();
onUpdate(updated);
alert('Данные обновлены');
} else {
alert('Ошибка при сохранении');
}
} catch (error) {
console.error('Error saving bailiff data:', error);
alert('Ошибка при сохранении');
} finally {
setLoading(false);
}
};
const handleReconciliation = async () => {
if (!confirm(`Подтвердите сверку прихода. Сумма ${formData.amountAtBailiffs} ₽ будет перенесена из "У приставов" в "Взыскано".`)) {
return;
}
setLoading(true);
try {
const newRecoveredAmount = (courtCase.recoveredAmount || 0) + (formData.amountAtBailiffs || 0);
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recoveredAmount: newRecoveredAmount,
amountAtBailiffs: 0,
changedBy: commentAuthor || 'System'
})
});
if (response.ok) {
const updated = await response.json();
onUpdate(updated);
setFormData({
...formData,
recoveredAmount: newRecoveredAmount,
amountAtBailiffs: 0
});
// Добавляем комментарий о сверке
if (commentAuthor) {
await authFetch(`/api/legal/court-cases/${courtCase.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
author: commentAuthor,
comment: `Сверка прихода выполнена. Перенесено ${formData.amountAtBailiffs} ₽ из ФССП в взысканные средства.`
})
});
await loadComments();
}
alert('Сверка прихода выполнена');
} else {
alert('Ошибка при сверке прихода');
}
} catch (error) {
console.error('Error reconciling payment:', error);
alert('Ошибка при сверке прихода');
} finally {
setLoading(false);
}
};
const handleAddPetition = async () => {
if (!newPetition.text.trim() || !commentAuthor.trim()) {
alert('Заполните текст ходатайства и автора');
return;
}
setLoading(true);
try {
const response = await authFetch(`/api/legal/court-cases/${courtCase.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
author: commentAuthor,
comment: `[ХОДАТАЙСТВО] ${newPetition.text}`
})
});
if (response.ok) {
setNewPetition({ ...newPetition, text: '' });
await loadComments();
alert('Ходатайство добавлено');
} else {
alert('Ошибка при добавлении ходатайства');
}
} catch (error) {
console.error('Error adding petition:', error);
alert('Ошибка при добавлении ходатайства');
} finally {
setLoading(false);
}
};
const lastAction = courtCase.fsspLastActionDate ? new Date(courtCase.fsspLastActionDate) : null;
const daysSinceLastAction = lastAction
? Math.floor((new Date().getTime() - lastAction.getTime()) / (1000 * 3600 * 24))
: null;
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-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-black text-slate-800">Взаимодействие с приставами</h3>
<p className="text-sm text-slate-500 mt-1">{courtCase.caseNumber}</p>
</div>
<button onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-6">
{/* Информация о приставах */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<h4 className="text-sm font-black text-amber-800 uppercase mb-4 flex items-center gap-2">
<CircleDollarSign className="w-5 h-5" /> Информация о приставах
</h4>
<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.enforcementNumber}
onChange={(e) => setFormData({ ...formData, enforcementNumber: e.target.value })}
placeholder="Номер исполнительного производства"
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-500 bg-white"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Дата возбуждения ИП</label>
<input
type="date"
value={formData.enforcementStartDate}
onChange={(e) => setFormData({ ...formData, enforcementStartDate: 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-amber-500 bg-white"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Этап ФССП</label>
<select
value={formData.fsspStage}
onChange={(e) => setFormData({ ...formData, fsspStage: 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-amber-500 bg-white"
>
<option value=""> не выбран </option>
{FSSP_STAGE_ORDER.map(stage => (
<option key={stage} value={stage}>{FSSP_STAGE_LABELS[stage]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Статус ФССП (текст)</label>
<input
type="text"
value={formData.fsspStatus}
onChange={(e) => setFormData({ ...formData, fsspStatus: e.target.value })}
placeholder="Например: Деньги на депозите"
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-500 bg-white"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">ФИО пристава</label>
<input
type="text"
value={formData.bailiffName}
onChange={(e) => setFormData({ ...formData, bailiffName: e.target.value })}
placeholder="Иванов И.И."
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-500 bg-white"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Дата последнего действия</label>
<input
type="date"
value={formData.fsspLastActionDate}
onChange={(e) => setFormData({ ...formData, fsspLastActionDate: 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-amber-500 bg-white"
/>
{daysSinceLastAction !== null && (
<p className={`text-xs mt-1 font-bold ${daysSinceLastAction > 20 ? 'text-red-600' : 'text-slate-600'}`}>
{daysSinceLastAction} дней без действий
</p>
)}
</div>
</div>
</div>
{/* Финансовые данные */}
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h4 className="text-sm font-black text-slate-800 uppercase mb-4 flex items-center gap-2">
<DollarSign className="w-5 h-5" /> Финансовые данные
</h4>
<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="number"
value={formData.amountAtBailiffs}
onChange={(e) => setFormData({ ...formData, amountAtBailiffs: 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-amber-500 bg-white"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Взыскано (УК)</label>
<input
type="number"
value={formData.recoveredAmount}
onChange={(e) => setFormData({ ...formData, recoveredAmount: 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-amber-500 bg-white"
/>
</div>
</div>
<button
onClick={handleReconciliation}
disabled={loading || !formData.amountAtBailiffs || formData.amountAtBailiffs <= 0}
className="mt-4 w-full px-4 py-2.5 bg-slate-900 text-white rounded-xl text-xs font-black uppercase hover:bg-slate-800 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4" /> Сверка прихода
</button>
</div>
{/* Ходатайства */}
<div className="border border-slate-200 rounded-xl p-4">
<h4 className="text-sm font-black text-slate-800 uppercase mb-4 flex items-center gap-2">
<Receipt className="w-5 h-5" /> Ходатайства
</h4>
<div className="space-y-3 mb-4 max-h-48 overflow-y-auto">
{comments.filter(c => c.comment.toLowerCase().includes('ходатайство')).length > 0 ? (
comments.filter(c => c.comment.toLowerCase().includes('ходатайство')).map((comment: any) => (
<div key={comment.id} className="bg-amber-50 p-3 rounded-xl border border-amber-200">
<div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-amber-800">{comment.author}</p>
<p className="text-xs text-slate-400">
{new Date(comment.createdAt).toLocaleDateString('ru-RU')}
</p>
</div>
<p className="text-sm text-slate-700">{comment.comment.replace('[ХОДАТАЙСТВО]', '').trim()}</p>
</div>
))
) : (
<div className="text-center py-8 text-slate-400">
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-20" />
<p className="text-xs font-bold uppercase">Ходатайств пока нет</p>
</div>
)}
</div>
<div className="border-t border-slate-200 pt-4">
<div className="space-y-3">
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Автор</label>
<input
type="text"
value={commentAuthor}
onChange={(e) => setCommentAuthor(e.target.value)}
placeholder="Ваше имя"
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Текст ходатайства</label>
<textarea
value={newPetition.text}
onChange={(e) => setNewPetition({ ...newPetition, text: e.target.value })}
rows={3}
placeholder="Введите текст ходатайства..."
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-500"
/>
</div>
<button
onClick={handleAddPetition}
disabled={loading || !newPetition.text.trim() || !commentAuthor.trim()}
className="w-full px-4 py-2 bg-amber-500 text-white rounded-xl text-xs font-black uppercase hover:bg-amber-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<Receipt className="w-4 h-4" /> {loading ? 'Добавление...' : 'Создать ходатайство'}
</button>
</div>
</div>
</div>
{/* История взаимодействий */}
<div className="border border-slate-200 rounded-xl p-4">
<h4 className="text-sm font-black text-slate-800 uppercase mb-4 flex items-center gap-2">
<History className="w-5 h-5" /> История взаимодействий
</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{comments.length > 0 ? (
comments.map((comment: any) => (
<div key={comment.id} className="bg-slate-50 p-3 rounded-xl border border-slate-200">
<div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-slate-800">{comment.author}</p>
<p className="text-xs text-slate-400">
{new Date(comment.createdAt).toLocaleDateString('ru-RU')}
</p>
</div>
<p className="text-sm text-slate-700">{comment.comment}</p>
</div>
))
) : (
<div className="text-center py-8 text-slate-400">
<History className="w-8 h-8 mx-auto mb-2 opacity-20" />
<p className="text-xs font-bold uppercase">История пуста</p>
</div>
)}
</div>
</div>
{/* Кнопки действий */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
onClick={handleSave}
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 flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" /> {loading ? 'Сохранение...' : 'Сохранить изменения'}
</button>
<button
onClick={onClose}
className="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>
</div>
</div>
</div>
</div>
);
};

392
components/legal/LegalSummary.tsx Executable file
View File

@@ -0,0 +1,392 @@
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>
);

1326
components/legal/PreTrialWork.tsx Executable file

File diff suppressed because it is too large Load Diff