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