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