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

631 lines
39 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } 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>
);
};