Initial commit MKD fixes
This commit is contained in:
630
components/legal/CaseDetailsModal.tsx
Executable file
630
components/legal/CaseDetailsModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
852
components/legal/ComplianceCheck.tsx
Executable file
852
components/legal/ComplianceCheck.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
584
components/legal/ContractsRegistry.tsx
Executable file
584
components/legal/ContractsRegistry.tsx
Executable 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
595
components/legal/CourtCases.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
915
components/legal/DebtRecoveryPipeline.tsx
Executable file
915
components/legal/DebtRecoveryPipeline.tsx
Executable 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
392
components/legal/LegalSummary.tsx
Executable 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
1326
components/legal/PreTrialWork.tsx
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user