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

585 lines
31 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 { 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>
);
};