Files
mkd/components/legal/ContractsRegistry.tsx

585 lines
31 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};