1327 lines
54 KiB
TypeScript
1327 lines
54 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|||
|
|
import {
|
|||
|
|
Phone,
|
|||
|
|
Mail,
|
|||
|
|
MapPin,
|
|||
|
|
Calendar,
|
|||
|
|
DollarSign,
|
|||
|
|
Search,
|
|||
|
|
Filter,
|
|||
|
|
Plus,
|
|||
|
|
X,
|
|||
|
|
Clock,
|
|||
|
|
CheckCircle2,
|
|||
|
|
AlertCircle,
|
|||
|
|
Gavel,
|
|||
|
|
FileText,
|
|||
|
|
User,
|
|||
|
|
MessageSquare,
|
|||
|
|
ChevronDown,
|
|||
|
|
ChevronUp
|
|||
|
|
} from 'lucide-react';
|
|||
|
|
import { authFetch, backendApi } from '../../services/apiClient';
|
|||
|
|
import { PreTrialWork as PreTrialWorkType, PreTrialAction, PromisedPayment } from '../../types';
|
|||
|
|
|
|||
|
|
export const PreTrialWork: React.FC = () => {
|
|||
|
|
const [works, setWorks] = useState<PreTrialWorkType[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [search, setSearch] = useState('');
|
|||
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|||
|
|
const [assignedToFilter, setAssignedToFilter] = useState<string>('all'); // 'all' | 'mine' | employee name
|
|||
|
|
const [sortBy, setSortBy] = useState<'updatedAt' | 'debtAmount' | 'promisedDate'>('updatedAt');
|
|||
|
|
const [currentUserName, setCurrentUserName] = useState<string | null>(null);
|
|||
|
|
const [employeesList, setEmployeesList] = useState<Array<{ id: string; name: string }>>([]);
|
|||
|
|
const [expandedWorkId, setExpandedWorkId] = useState<number | null>(null);
|
|||
|
|
const [showActionModal, setShowActionModal] = useState(false);
|
|||
|
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
|||
|
|
const [showTransferModal, setShowTransferModal] = useState(false);
|
|||
|
|
const [selectedWork, setSelectedWork] = useState<PreTrialWorkType | null>(null);
|
|||
|
|
const [showAddDebtorModal, setShowAddDebtorModal] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
backendApi.getMe().then(u => setCurrentUserName(u?.name ?? null)).catch(() => setCurrentUserName(null));
|
|||
|
|
backendApi.getEmployeesList().then(list => setEmployeesList(Array.isArray(list) ? list : [])).catch(() => setEmployeesList([]));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadWorks();
|
|||
|
|
}, [statusFilter, search, assignedToFilter]);
|
|||
|
|
|
|||
|
|
const loadWorks = async () => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
if (statusFilter !== 'all') {
|
|||
|
|
params.append('status', statusFilter);
|
|||
|
|
}
|
|||
|
|
if (search) {
|
|||
|
|
params.append('search', search);
|
|||
|
|
}
|
|||
|
|
if (assignedToFilter === 'mine' && currentUserName) {
|
|||
|
|
params.append('assignedTo', currentUserName);
|
|||
|
|
} else if (assignedToFilter && assignedToFilter !== 'all') {
|
|||
|
|
params.append('assignedTo', assignedToFilter);
|
|||
|
|
}
|
|||
|
|
const response = await authFetch(`/api/legal/pre-trial-work?${params}`);
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|||
|
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
// Убеждаемся, что data - это массив и обрабатываем данные
|
|||
|
|
if (Array.isArray(data)) {
|
|||
|
|
// Фильтруем работы с валидными данными должника
|
|||
|
|
const validWorks = data.filter((work: any) => work && work.debtor);
|
|||
|
|
setWorks(validWorks);
|
|||
|
|
} else {
|
|||
|
|
setWorks([]);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error loading pre-trial work:', error);
|
|||
|
|
setError(error instanceof Error ? error.message : 'Ошибка загрузки данных');
|
|||
|
|
setWorks([]);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleAddAction = (work: PreTrialWorkType) => {
|
|||
|
|
setSelectedWork(work);
|
|||
|
|
setShowActionModal(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleAddPayment = (work: PreTrialWorkType) => {
|
|||
|
|
setSelectedWork(work);
|
|||
|
|
setShowPaymentModal(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleTransferToCourt = (work: PreTrialWorkType) => {
|
|||
|
|
setSelectedWork(work);
|
|||
|
|
setShowTransferModal(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getStatusColor = (status: string) => {
|
|||
|
|
switch (status) {
|
|||
|
|
case 'new': return 'bg-slate-100 text-slate-600';
|
|||
|
|
case 'in_progress': return 'bg-blue-100 text-blue-600';
|
|||
|
|
case 'promised_payment': return 'bg-amber-100 text-amber-600';
|
|||
|
|
case 'transferred_to_court': return 'bg-purple-100 text-purple-600';
|
|||
|
|
case 'resolved': return 'bg-emerald-100 text-emerald-600';
|
|||
|
|
default: return 'bg-slate-100 text-slate-600';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getStatusLabel = (status: string) => {
|
|||
|
|
switch (status) {
|
|||
|
|
case 'new': return 'Новое';
|
|||
|
|
case 'in_progress': return 'В работе';
|
|||
|
|
case 'promised_payment': return 'Обещана оплата';
|
|||
|
|
case 'transferred_to_court': return 'Передано в суд';
|
|||
|
|
case 'resolved': return 'Решено';
|
|||
|
|
default: return status;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getActionIcon = (type: string) => {
|
|||
|
|
switch (type) {
|
|||
|
|
case 'call': return Phone;
|
|||
|
|
case 'letter': return Mail;
|
|||
|
|
case 'visit': return MapPin;
|
|||
|
|
default: return FileText;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getActionLabel = (type: string) => {
|
|||
|
|
switch (type) {
|
|||
|
|
case 'call': return 'Звонок';
|
|||
|
|
case 'letter': return 'Письмо';
|
|||
|
|
case 'visit': return 'Визит';
|
|||
|
|
default: return type;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
|||
|
|
const attentionItems = useMemo(() => {
|
|||
|
|
const overdue: Array<{ work: PreTrialWorkType; payment: PromisedPayment }> = [];
|
|||
|
|
const noAssignee: PreTrialWorkType[] = [];
|
|||
|
|
for (const work of works) {
|
|||
|
|
if (!work.debtor) continue;
|
|||
|
|
if (
|
|||
|
|
work.status !== 'transferred_to_court' &&
|
|||
|
|
work.status !== 'resolved' &&
|
|||
|
|
!work.assignedTo
|
|||
|
|
) {
|
|||
|
|
noAssignee.push(work);
|
|||
|
|
}
|
|||
|
|
const payments = work.promisedPayments || [];
|
|||
|
|
for (const p of payments) {
|
|||
|
|
if (!p.isPaid && p.promisedDate < today) {
|
|||
|
|
overdue.push({ work, payment: p });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { overdue, noAssignee };
|
|||
|
|
}, [works, today]);
|
|||
|
|
|
|||
|
|
const sortedWorks = useMemo(() => {
|
|||
|
|
const list = works.filter((w): w is PreTrialWorkType & { debtor: NonNullable<PreTrialWorkType['debtor']> } => !!w?.debtor);
|
|||
|
|
if (sortBy === 'updatedAt') {
|
|||
|
|
return [...list].sort((a, b) => {
|
|||
|
|
const da = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|||
|
|
const db = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|||
|
|
return db - da;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
if (sortBy === 'debtAmount') {
|
|||
|
|
return [...list].sort((a, b) => (b.debtor?.debtAmount ?? 0) - (a.debtor?.debtAmount ?? 0));
|
|||
|
|
}
|
|||
|
|
if (sortBy === 'promisedDate') {
|
|||
|
|
const getNearest = (w: typeof list[0]) => {
|
|||
|
|
const payments = w.promisedPayments?.filter(p => !p.isPaid) ?? [];
|
|||
|
|
if (payments.length === 0) return '';
|
|||
|
|
return payments.map(p => p.promisedDate).sort()[0] ?? '';
|
|||
|
|
};
|
|||
|
|
return [...list].sort((a, b) => {
|
|||
|
|
const pa = getNearest(a);
|
|||
|
|
const pb = getNearest(b);
|
|||
|
|
if (!pa) return 1;
|
|||
|
|
if (!pb) return -1;
|
|||
|
|
return pa.localeCompare(pb);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return list;
|
|||
|
|
}, [works, sortBy]);
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|||
|
|
<div className="text-slate-400">Загрузка...</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (error) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
|||
|
|
<div className="text-red-600 font-bold">Ошибка загрузки данных</div>
|
|||
|
|
<div className="text-sm text-slate-600">{error}</div>
|
|||
|
|
<button
|
|||
|
|
onClick={loadWorks}
|
|||
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase hover:bg-primary-700 transition-colors"
|
|||
|
|
>
|
|||
|
|
Попробовать снова
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
{/* Header with Search and Filters */}
|
|||
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|||
|
|
<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-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
<select
|
|||
|
|
value={statusFilter}
|
|||
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|||
|
|
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
|
|||
|
|
>
|
|||
|
|
<option value="all">Все статусы</option>
|
|||
|
|
<option value="new">Новые</option>
|
|||
|
|
<option value="in_progress">В работе</option>
|
|||
|
|
<option value="promised_payment">Обещана оплата</option>
|
|||
|
|
<option value="transferred_to_court">В суде</option>
|
|||
|
|
<option value="resolved">Решено</option>
|
|||
|
|
</select>
|
|||
|
|
<select
|
|||
|
|
value={assignedToFilter}
|
|||
|
|
onChange={(e) => setAssignedToFilter(e.target.value)}
|
|||
|
|
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
|
|||
|
|
title="Ответственный"
|
|||
|
|
>
|
|||
|
|
<option value="all">Все ответственные</option>
|
|||
|
|
<option value="mine">Мои должники</option>
|
|||
|
|
{employeesList.map((emp) => (
|
|||
|
|
<option key={emp.id} value={emp.name}>{emp.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<select
|
|||
|
|
value={sortBy}
|
|||
|
|
onChange={(e) => setSortBy(e.target.value as 'updatedAt' | 'debtAmount' | 'promisedDate')}
|
|||
|
|
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
|
|||
|
|
title="Сортировка"
|
|||
|
|
>
|
|||
|
|
<option value="updatedAt">По дате обновления</option>
|
|||
|
|
<option value="debtAmount">По сумме долга</option>
|
|||
|
|
<option value="promisedDate">По сроку обещанной оплаты</option>
|
|||
|
|
</select>
|
|||
|
|
<button className="p-2.5 bg-white border border-slate-200 rounded-xl text-slate-500 hover:bg-slate-50" title="Фильтры">
|
|||
|
|
<Filter className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowAddDebtorModal(true)}
|
|||
|
|
className="bg-primary-600 text-white px-4 py-2.5 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>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Statistics */}
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
|||
|
|
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
|||
|
|
<p className="text-[10px] font-black text-slate-400 uppercase mb-1">Всего дел</p>
|
|||
|
|
<p className="text-2xl font-black text-slate-800">{works.length}</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
|||
|
|
<p className="text-[10px] font-black text-slate-400 uppercase mb-1">В работе</p>
|
|||
|
|
<p className="text-2xl font-black text-blue-600">
|
|||
|
|
{works.filter(w => w.status === 'in_progress').length}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
|||
|
|
<p className="text-[10px] font-black text-slate-400 uppercase mb-1">Обещана оплата</p>
|
|||
|
|
<p className="text-2xl font-black text-amber-600">
|
|||
|
|
{works.filter(w => w.status === 'promised_payment').length}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
|||
|
|
<p className="text-[10px] font-black text-slate-400 uppercase mb-1">В суде</p>
|
|||
|
|
<p className="text-2xl font-black text-purple-600">
|
|||
|
|
{works.filter(w => w.status === 'transferred_to_court').length}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Требует внимания */}
|
|||
|
|
{(attentionItems.overdue.length > 0 || attentionItems.noAssignee.length > 0) && (
|
|||
|
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 shadow-sm">
|
|||
|
|
<h3 className="text-sm font-black text-amber-900 uppercase mb-3 flex items-center gap-2">
|
|||
|
|
<AlertCircle className="w-4 h-4" />
|
|||
|
|
Требует внимания
|
|||
|
|
</h3>
|
|||
|
|
<ul className="space-y-2 text-sm">
|
|||
|
|
{attentionItems.overdue.length > 0 && (
|
|||
|
|
<li className="text-amber-800">
|
|||
|
|
<span className="font-bold">Просроченные обещанные платежи:</span>{' '}
|
|||
|
|
{attentionItems.overdue.length}{' '}
|
|||
|
|
{attentionItems.overdue.length === 1 ? 'дело' : 'дел'}
|
|||
|
|
{attentionItems.overdue.slice(0, 3).map(({ work, payment }) => (
|
|||
|
|
<span key={`${work.id}-${payment.id}`} className="block text-xs mt-1 text-amber-700">
|
|||
|
|
{work.debtor?.address}, кв. {work.debtor?.apartment} — {new Date(payment.promisedDate).toLocaleDateString('ru-RU')}, {payment.promisedAmount?.toLocaleString()} ₽
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
{attentionItems.overdue.length > 3 && (
|
|||
|
|
<span className="block text-xs text-amber-600">и ещё {attentionItems.overdue.length - 3}</span>
|
|||
|
|
)}
|
|||
|
|
</li>
|
|||
|
|
)}
|
|||
|
|
{attentionItems.noAssignee.length > 0 && (
|
|||
|
|
<li className="text-amber-800">
|
|||
|
|
<span className="font-bold">Без ответственного:</span> {attentionItems.noAssignee.length}{' '}
|
|||
|
|
{attentionItems.noAssignee.length === 1 ? 'дело' : 'дел'}
|
|||
|
|
{attentionItems.noAssignee.slice(0, 3).map((w) => (
|
|||
|
|
<span key={w.id} className="block text-xs mt-1 text-amber-700">
|
|||
|
|
{w.debtor?.address}, кв. {w.debtor?.apartment}
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
{attentionItems.noAssignee.length > 3 && (
|
|||
|
|
<span className="block text-xs text-amber-600">и ещё {attentionItems.noAssignee.length - 3}</span>
|
|||
|
|
)}
|
|||
|
|
</li>
|
|||
|
|
)}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Works List */}
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{sortedWorks.length === 0 ? (
|
|||
|
|
<div className="bg-white p-12 rounded-xl border border-slate-200 text-center">
|
|||
|
|
<p className="text-slate-400">Нет дел для отображения</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
sortedWorks
|
|||
|
|
.map((work) => {
|
|||
|
|
if (!work.debtor) return null;
|
|||
|
|
return (
|
|||
|
|
<WorkCard
|
|||
|
|
key={work.id}
|
|||
|
|
work={work}
|
|||
|
|
expanded={expandedWorkId === work.id}
|
|||
|
|
onToggleExpand={() => setExpandedWorkId(expandedWorkId === work.id ? null : work.id)}
|
|||
|
|
onAddAction={() => handleAddAction(work)}
|
|||
|
|
onAddPayment={() => handleAddPayment(work)}
|
|||
|
|
onTransferToCourt={() => handleTransferToCourt(work)}
|
|||
|
|
getStatusColor={getStatusColor}
|
|||
|
|
getStatusLabel={getStatusLabel}
|
|||
|
|
getActionIcon={getActionIcon}
|
|||
|
|
getActionLabel={getActionLabel}
|
|||
|
|
onReload={loadWorks}
|
|||
|
|
employeesList={employeesList}
|
|||
|
|
onAssignChange={(workId, assignedTo) => {
|
|||
|
|
backendApi.updatePreTrialWork(workId, { assignedTo: assignedTo || undefined }).then(() => {
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-legal-changed'));
|
|||
|
|
loadWorks();
|
|||
|
|
}).catch(() => alert('Ошибка при смене ответственного'));
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
.filter(Boolean) // Убираем null значения
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Action Modal */}
|
|||
|
|
{showActionModal && selectedWork && (
|
|||
|
|
<ActionModal
|
|||
|
|
work={selectedWork}
|
|||
|
|
onClose={() => {
|
|||
|
|
setShowActionModal(false);
|
|||
|
|
setSelectedWork(null);
|
|||
|
|
}}
|
|||
|
|
onSuccess={() => {
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-legal-changed'));
|
|||
|
|
loadWorks();
|
|||
|
|
setShowActionModal(false);
|
|||
|
|
setSelectedWork(null);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Payment Modal */}
|
|||
|
|
{showPaymentModal && selectedWork && (
|
|||
|
|
<PaymentModal
|
|||
|
|
work={selectedWork}
|
|||
|
|
onClose={() => {
|
|||
|
|
setShowPaymentModal(false);
|
|||
|
|
setSelectedWork(null);
|
|||
|
|
}}
|
|||
|
|
onSuccess={() => {
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-legal-changed'));
|
|||
|
|
loadWorks();
|
|||
|
|
setShowPaymentModal(false);
|
|||
|
|
setSelectedWork(null);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Transfer Modal */}
|
|||
|
|
{showTransferModal && selectedWork && (
|
|||
|
|
<TransferModal
|
|||
|
|
work={selectedWork}
|
|||
|
|
onClose={() => {
|
|||
|
|
setShowTransferModal(false);
|
|||
|
|
setSelectedWork(null);
|
|||
|
|
}}
|
|||
|
|
onSuccess={() => {
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-legal-changed'));
|
|||
|
|
loadWorks();
|
|||
|
|
setShowTransferModal(false);
|
|||
|
|
setSelectedWork(null);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Add Debtor Modal */}
|
|||
|
|
{showAddDebtorModal && (
|
|||
|
|
<AddDebtorModal
|
|||
|
|
onClose={() => setShowAddDebtorModal(false)}
|
|||
|
|
onSuccess={() => {
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-legal-changed'));
|
|||
|
|
loadWorks();
|
|||
|
|
setShowAddDebtorModal(false);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Add Debtor Modal Component
|
|||
|
|
interface AddDebtorModalProps {
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSuccess: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const AddDebtorModal: React.FC<AddDebtorModalProps> = ({ onClose, onSuccess }) => {
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [buildings, setBuildings] = useState<{ id: string; address?: string; districtId?: string }[]>([]);
|
|||
|
|
const [formData, setFormData] = useState({
|
|||
|
|
buildingId: '',
|
|||
|
|
apartment: '',
|
|||
|
|
debtorName: '',
|
|||
|
|
phone: '',
|
|||
|
|
email: '',
|
|||
|
|
address: '',
|
|||
|
|
debtAmount: '',
|
|||
|
|
debtMonths: ''
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
authFetch('/api/buildings')
|
|||
|
|
.then(res => res.ok ? res.json() : [])
|
|||
|
|
.then(data => setBuildings(Array.isArray(data) ? data : []))
|
|||
|
|
.catch(() => setBuildings([]));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch('/api/legal/debtors', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
buildingId: formData.buildingId || undefined,
|
|||
|
|
apartment: formData.apartment,
|
|||
|
|
debtorName: formData.debtorName || undefined,
|
|||
|
|
phone: formData.phone || undefined,
|
|||
|
|
email: formData.email || undefined,
|
|||
|
|
address: formData.address,
|
|||
|
|
debtAmount: parseFloat(formData.debtAmount),
|
|||
|
|
debtMonths: parseInt(formData.debtMonths)
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (response.ok) {
|
|||
|
|
onSuccess();
|
|||
|
|
} else {
|
|||
|
|
const error = await response.json();
|
|||
|
|
alert(error.error || 'Ошибка при создании должника');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error creating debtor:', error);
|
|||
|
|
alert('Ошибка при создании должника');
|
|||
|
|
} 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-md w-full">
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Здание (объект МКД)</label>
|
|||
|
|
<select
|
|||
|
|
value={formData.buildingId}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, buildingId: 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"
|
|||
|
|
>
|
|||
|
|
<option value="">— не выбрано —</option>
|
|||
|
|
{buildings.map(b => (
|
|||
|
|
<option key={b.id} value={b.id}>
|
|||
|
|
{(b.passport?.address ?? b.address) || b.id}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</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"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Квартира *</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.apartment}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, apartment: 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.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 className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Телефон</label>
|
|||
|
|
<input
|
|||
|
|
type="tel"
|
|||
|
|
value={formData.phone}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, phone: 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">Email</label>
|
|||
|
|
<input
|
|||
|
|
type="email"
|
|||
|
|
value={formData.email}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, email: 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"
|
|||
|
|
value={formData.debtAmount}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, debtAmount: 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"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Месяцев долга *</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={formData.debtMonths}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, debtMonths: 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"
|
|||
|
|
min="1"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</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>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Одна строка обещанной оплаты с кнопкой «Отметить оплачено»
|
|||
|
|
const PromisedPaymentRow: React.FC<{
|
|||
|
|
payment: PromisedPayment;
|
|||
|
|
onMarkPaid: () => void;
|
|||
|
|
}> = ({ payment, onMarkPaid }) => {
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const handleMarkPaid = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const res = await authFetch(`/api/legal/promised-payments/${payment.id}/mark-paid`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
actualPaymentDate: new Date().toISOString().slice(0, 10),
|
|||
|
|
actualPaymentAmount: payment.promisedAmount
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (res.ok) onMarkPaid();
|
|||
|
|
else alert('Ошибка при отметке оплаты');
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
alert('Ошибка при отметке оплаты');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={`p-3 rounded-xl border-2 ${
|
|||
|
|
payment.isPaid
|
|||
|
|
? 'bg-emerald-50 border-emerald-200'
|
|||
|
|
: new Date(payment.promisedDate) < new Date()
|
|||
|
|
? 'bg-red-50 border-red-200'
|
|||
|
|
: 'bg-amber-50 border-amber-200'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Calendar className="w-4 h-4 text-slate-600" />
|
|||
|
|
<span className="text-sm font-black text-slate-800">
|
|||
|
|
{new Date(payment.promisedDate).toLocaleDateString('ru-RU')}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<DollarSign className="w-4 h-4 text-slate-600" />
|
|||
|
|
<span className="text-sm font-black text-slate-800">
|
|||
|
|
{payment.promisedAmount.toLocaleString()} ₽
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
{payment.isPaid ? (
|
|||
|
|
<div className="flex items-center gap-1 text-emerald-600">
|
|||
|
|
<CheckCircle2 className="w-4 h-4" />
|
|||
|
|
<span className="text-xs font-black">Оплачено</span>
|
|||
|
|
</div>
|
|||
|
|
) : new Date(payment.promisedDate) < new Date() ? (
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="flex items-center gap-1 text-red-600">
|
|||
|
|
<AlertCircle className="w-4 h-4" />
|
|||
|
|
<span className="text-xs font-black">Просрочено</span>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleMarkPaid}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="text-[10px] font-black uppercase px-2.5 py-1.5 rounded-lg bg-emerald-100 text-emerald-700 hover:bg-emerald-200 disabled:opacity-50 transition-colors"
|
|||
|
|
>
|
|||
|
|
{loading ? 'Сохранение...' : 'Отметить оплачено'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="flex items-center gap-1 text-amber-600">
|
|||
|
|
<Clock className="w-4 h-4" />
|
|||
|
|
<span className="text-xs font-black">Ожидается</span>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleMarkPaid}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="text-[10px] font-black uppercase px-2.5 py-1.5 rounded-lg bg-emerald-100 text-emerald-700 hover:bg-emerald-200 disabled:opacity-50 transition-colors"
|
|||
|
|
>
|
|||
|
|
{loading ? 'Сохранение...' : 'Отметить оплачено'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{payment.actualPaymentDate && (
|
|||
|
|
<p className="text-xs text-slate-600 mt-1">
|
|||
|
|
Фактически оплачено: {new Date(payment.actualPaymentDate).toLocaleDateString('ru-RU')}
|
|||
|
|
{payment.actualPaymentAmount != null && ` - ${payment.actualPaymentAmount.toLocaleString()} ₽`}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
interface WorkCardProps {
|
|||
|
|
work: PreTrialWorkType;
|
|||
|
|
expanded: boolean;
|
|||
|
|
onToggleExpand: () => void;
|
|||
|
|
onAddAction: () => void;
|
|||
|
|
onAddPayment: () => void;
|
|||
|
|
onTransferToCourt: () => void;
|
|||
|
|
getStatusColor: (status: string) => string;
|
|||
|
|
getStatusLabel: (status: string) => string;
|
|||
|
|
getActionIcon: (type: string) => any;
|
|||
|
|
getActionLabel: (type: string) => string;
|
|||
|
|
onReload: () => void;
|
|||
|
|
employeesList: Array<{ id: string; name: string }>;
|
|||
|
|
onAssignChange: (workId: number, assignedTo: string | null) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const WorkCard: React.FC<WorkCardProps> = ({
|
|||
|
|
work,
|
|||
|
|
expanded,
|
|||
|
|
onToggleExpand,
|
|||
|
|
onAddAction,
|
|||
|
|
onAddPayment,
|
|||
|
|
onTransferToCourt,
|
|||
|
|
getStatusColor,
|
|||
|
|
getStatusLabel,
|
|||
|
|
getActionIcon,
|
|||
|
|
getActionLabel,
|
|||
|
|
onReload,
|
|||
|
|
employeesList,
|
|||
|
|
onAssignChange
|
|||
|
|
}) => {
|
|||
|
|
const debtor = work.debtor;
|
|||
|
|
if (!debtor) {
|
|||
|
|
console.warn('WorkCard: debtor is missing', work);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const actions = work.actions || [];
|
|||
|
|
const payments = work.promisedPayments || [];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-sm hover:shadow-md transition-all">
|
|||
|
|
<div className="p-5">
|
|||
|
|
<div className="flex items-start justify-between mb-4">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<div className="flex flex-wrap items-center gap-3 mb-2">
|
|||
|
|
<div className={`px-3 py-1 rounded-full text-[10px] font-black uppercase ${getStatusColor(work.status)}`}>
|
|||
|
|
{getStatusLabel(work.status)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<User className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
|||
|
|
<select
|
|||
|
|
value={work.assignedTo || ''}
|
|||
|
|
onChange={(e) => onAssignChange(work.id, e.target.value || null)}
|
|||
|
|
className="text-[10px] font-medium text-slate-600 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1 outline-none focus:ring-1 focus:ring-primary-500 min-w-[120px]"
|
|||
|
|
title="Ответственный"
|
|||
|
|
>
|
|||
|
|
<option value="">— не назначен —</option>
|
|||
|
|
{employeesList.map((emp) => (
|
|||
|
|
<option key={emp.id} value={emp.name}>{emp.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<h3 className="text-lg font-black text-slate-800 mb-1">
|
|||
|
|
{debtor.address}, кв. {debtor.apartment}
|
|||
|
|
</h3>
|
|||
|
|
{debtor.debtorName && (
|
|||
|
|
<p className="text-sm text-slate-600 mb-2">{debtor.debtorName}</p>
|
|||
|
|
)}
|
|||
|
|
<div className="flex items-center gap-4 text-xs text-slate-500">
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
<DollarSign className="w-4 h-4" />
|
|||
|
|
<span className="font-black text-red-600">{debtor.debtAmount.toLocaleString()} ₽</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
<Clock className="w-4 h-4" />
|
|||
|
|
<span>{debtor.debtMonths} мес. долга</span>
|
|||
|
|
</div>
|
|||
|
|
{debtor.phone && (
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
<Phone className="w-4 h-4" />
|
|||
|
|
<span>{debtor.phone}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={onToggleExpand}
|
|||
|
|
className="p-2 text-slate-400 hover:text-slate-600 transition-colors"
|
|||
|
|
>
|
|||
|
|
{expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Action Buttons */}
|
|||
|
|
{work.status !== 'transferred_to_court' && work.status !== 'resolved' && (
|
|||
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|||
|
|
<button
|
|||
|
|
onClick={onAddAction}
|
|||
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary-50 text-primary-600 rounded-xl text-xs font-black uppercase hover:bg-primary-100 transition-colors"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" />
|
|||
|
|
Добавить действие
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={onAddPayment}
|
|||
|
|
className="flex items-center gap-2 px-4 py-2 bg-amber-50 text-amber-600 rounded-xl text-xs font-black uppercase hover:bg-amber-100 transition-colors"
|
|||
|
|
>
|
|||
|
|
<Calendar className="w-4 h-4" />
|
|||
|
|
Обещанная оплата
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={onTransferToCourt}
|
|||
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-50 text-purple-600 rounded-xl text-xs font-black uppercase hover:bg-purple-100 transition-colors"
|
|||
|
|
>
|
|||
|
|
<Gavel className="w-4 h-4" />
|
|||
|
|
Передать в суд
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Expanded Content */}
|
|||
|
|
{expanded && (
|
|||
|
|
<div className="space-y-4 pt-4 border-t border-slate-100">
|
|||
|
|
{/* Actions History */}
|
|||
|
|
{actions.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<h4 className="text-sm font-black text-slate-700 mb-3 uppercase">История действий</h4>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{actions.map((action) => {
|
|||
|
|
const Icon = getActionIcon(action.actionType);
|
|||
|
|
return (
|
|||
|
|
<div key={action.id} className="flex items-start gap-3 p-3 bg-slate-50 rounded-xl">
|
|||
|
|
<div className="p-2 bg-white rounded-lg">
|
|||
|
|
<Icon className="w-4 h-4 text-slate-600" />
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<div className="flex items-center gap-2 mb-1">
|
|||
|
|
<span className="text-xs font-black text-slate-800">
|
|||
|
|
{getActionLabel(action.actionType)}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-[10px] text-slate-400">
|
|||
|
|
{new Date(action.actionDate).toLocaleDateString('ru-RU')}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-slate-600 mb-1">
|
|||
|
|
Выполнил: {action.performedBy}
|
|||
|
|
</p>
|
|||
|
|
{action.result && (
|
|||
|
|
<p className="text-xs text-slate-700">{action.result}</p>
|
|||
|
|
)}
|
|||
|
|
{action.notes && (
|
|||
|
|
<p className="text-xs text-slate-500 mt-1">{action.notes}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Promised Payments */}
|
|||
|
|
{payments.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<h4 className="text-sm font-black text-slate-700 mb-3 uppercase">Обещанные оплаты</h4>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{payments.map((payment) => (
|
|||
|
|
<PromisedPaymentRow
|
|||
|
|
key={payment.id}
|
|||
|
|
payment={payment}
|
|||
|
|
onMarkPaid={onReload}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Notes */}
|
|||
|
|
{work.notes && (
|
|||
|
|
<div>
|
|||
|
|
<h4 className="text-sm font-black text-slate-700 mb-2 uppercase">Примечания</h4>
|
|||
|
|
<p className="text-xs text-slate-600 bg-slate-50 p-3 rounded-xl">{work.notes}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Action Modal Component
|
|||
|
|
interface ActionModalProps {
|
|||
|
|
work: PreTrialWorkType;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSuccess: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ActionModal: React.FC<ActionModalProps> = ({ work, onClose, onSuccess }) => {
|
|||
|
|
const [actionType, setActionType] = useState<'call' | 'letter' | 'visit'>('call');
|
|||
|
|
const [actionDate, setActionDate] = useState(new Date().toISOString().slice(0, 16));
|
|||
|
|
const [performedBy, setPerformedBy] = useState('');
|
|||
|
|
const [result, setResult] = useState('');
|
|||
|
|
const [notes, setNotes] = useState('');
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch(`/api/legal/pre-trial-work/${work.id}/actions`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
actionType,
|
|||
|
|
actionDate: new Date(actionDate).toISOString(),
|
|||
|
|
performedBy,
|
|||
|
|
result,
|
|||
|
|
notes
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (response.ok) {
|
|||
|
|
onSuccess();
|
|||
|
|
} else {
|
|||
|
|
alert('Ошибка при добавлении действия');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error adding action:', error);
|
|||
|
|
alert('Ошибка при добавлении действия');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|||
|
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<h3 className="text-lg font-black text-slate-800">Добавить действие</h3>
|
|||
|
|
<button onClick={onClose} className="p-1 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>
|
|||
|
|
<select
|
|||
|
|
value={actionType}
|
|||
|
|
onChange={(e) => setActionType(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="call">Звонок</option>
|
|||
|
|
<option value="letter">Письмо</option>
|
|||
|
|
<option value="visit">Визит</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Дата и время</label>
|
|||
|
|
<input
|
|||
|
|
type="datetime-local"
|
|||
|
|
value={actionDate}
|
|||
|
|
onChange={(e) => setActionDate(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={performedBy}
|
|||
|
|
onChange={(e) => setPerformedBy(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>
|
|||
|
|
<textarea
|
|||
|
|
value={result}
|
|||
|
|
onChange={(e) => setResult(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"
|
|||
|
|
placeholder="Опишите результат действия..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Примечания</label>
|
|||
|
|
<textarea
|
|||
|
|
value={notes}
|
|||
|
|
onChange={(e) => setNotes(e.target.value)}
|
|||
|
|
rows={2}
|
|||
|
|
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-2">
|
|||
|
|
<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>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Payment Modal Component
|
|||
|
|
interface PaymentModalProps {
|
|||
|
|
work: PreTrialWorkType;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSuccess: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PaymentModal: React.FC<PaymentModalProps> = ({ work, onClose, onSuccess }) => {
|
|||
|
|
const [promisedDate, setPromisedDate] = useState('');
|
|||
|
|
const [promisedAmount, setPromisedAmount] = useState('');
|
|||
|
|
const [notes, setNotes] = useState('');
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const tomorrow = new Date();
|
|||
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|||
|
|
setPromisedDate(tomorrow.toISOString().slice(0, 10));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/legal/pre-trial-work/${work.id}/promised-payment`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
promisedDate,
|
|||
|
|
promisedAmount: parseFloat(promisedAmount),
|
|||
|
|
notes
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (response.ok) {
|
|||
|
|
onSuccess();
|
|||
|
|
} else {
|
|||
|
|
alert('Ошибка при добавлении обещанной оплаты');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error adding promised payment:', error);
|
|||
|
|
alert('Ошибка при добавлении обещанной оплаты');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|||
|
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<h3 className="text-lg font-black text-slate-800">Обещанная оплата</h3>
|
|||
|
|
<button onClick={onClose} className="p-1 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="date"
|
|||
|
|
value={promisedDate}
|
|||
|
|
onChange={(e) => setPromisedDate(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="number"
|
|||
|
|
value={promisedAmount}
|
|||
|
|
onChange={(e) => setPromisedAmount(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="0"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">Примечания</label>
|
|||
|
|
<textarea
|
|||
|
|
value={notes}
|
|||
|
|
onChange={(e) => setNotes(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-2">
|
|||
|
|
<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>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Transfer Modal Component
|
|||
|
|
interface TransferModalProps {
|
|||
|
|
work: PreTrialWorkType;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSuccess: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const TransferModal: React.FC<TransferModalProps> = ({ work, onClose, onSuccess }) => {
|
|||
|
|
const [courtCaseId, setCourtCaseId] = useState('');
|
|||
|
|
const [courtCaseNumber, setCourtCaseNumber] = useState('');
|
|||
|
|
const [subject, setSubject] = useState('');
|
|||
|
|
const [courtName, setCourtName] = useState('');
|
|||
|
|
const [judge, setJudge] = useState('');
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
|
|||
|
|
// Автоматически заполняем предмет дела на основе данных должника
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (work.debtor && !subject) {
|
|||
|
|
const autoSubject = `Взыскание задолженности по оплате жилищно-коммунальных услуг. ${work.debtor.address || ''}, кв. ${work.debtor.apartment || ''}`;
|
|||
|
|
setSubject(autoSubject);
|
|||
|
|
}
|
|||
|
|
}, [work.debtor, subject]);
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (!confirm('Вы уверены, что хотите передать это дело в суд? Будет автоматически создано судебное дело.')) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch(`/api/legal/pre-trial-work/${work.id}/transfer-to-court`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
courtCaseId: courtCaseId || undefined,
|
|||
|
|
courtCaseNumber: courtCaseNumber || undefined,
|
|||
|
|
subject: subject || undefined,
|
|||
|
|
courtName: courtName || undefined,
|
|||
|
|
judge: judge || undefined
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (response.ok) {
|
|||
|
|
const result = await response.json();
|
|||
|
|
alert(result.message || 'Дело успешно передано в суд');
|
|||
|
|
onSuccess();
|
|||
|
|
} else {
|
|||
|
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|||
|
|
alert(error.error || 'Ошибка при передаче в суд');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error transferring to court:', error);
|
|||
|
|
alert('Ошибка при передаче в суд');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|||
|
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<h3 className="text-lg font-black text-slate-800">Передать в суд</h3>
|
|||
|
|
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600">
|
|||
|
|
<X className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3">
|
|||
|
|
<p className="text-xs text-amber-800">
|
|||
|
|
После передачи в суд дело будет перемещено в модуль "Взыскание" для дальнейшей работы.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">
|
|||
|
|
Номер судебного дела (если уже существует)
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={courtCaseId}
|
|||
|
|
onChange={(e) => setCourtCaseId(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 mb-2"
|
|||
|
|
placeholder="А40-12345/2024 (оставьте пустым для автогенерации)"
|
|||
|
|
/>
|
|||
|
|
<p className="text-[10px] text-slate-500 mb-3">
|
|||
|
|
Если оставить пустым, будет автоматически создано новое судебное дело
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">
|
|||
|
|
Номер дела (для нового дела)
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={courtCaseNumber}
|
|||
|
|
onChange={(e) => setCourtCaseNumber(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 (автогенерация, если пусто)"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">
|
|||
|
|
Предмет дела *
|
|||
|
|
</label>
|
|||
|
|
<textarea
|
|||
|
|
value={subject}
|
|||
|
|
onChange={(e) => setSubject(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"
|
|||
|
|
placeholder="Взыскание задолженности..."
|
|||
|
|
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={courtName}
|
|||
|
|
onChange={(e) => setCourtName(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="Арбитражный суд г. Москвы"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-black text-slate-700 uppercase mb-1">
|
|||
|
|
Судья
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={judge}
|
|||
|
|
onChange={(e) => setJudge(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="Иванов И.И."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-3 pt-2">
|
|||
|
|
<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-purple-600 text-white rounded-xl text-xs font-black uppercase hover:bg-purple-700 transition-colors disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{loading ? 'Передача...' : 'Передать в суд'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|