1069 lines
64 KiB
TypeScript
1069 lines
64 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { authFetch } from '../../services/apiClient';
|
|||
|
|
import { Monitor, Coffee, Package, Clock, CheckCircle2, QrCode, Plus, X, Trash2, ArrowRightLeft, History } from 'lucide-react';
|
|||
|
|
import { OfficeEquipment, Employee, OfficeEquipmentHistoryItem } from '../../types';
|
|||
|
|
|
|||
|
|
// Компонент для отображения количества на складе
|
|||
|
|
const InventoryCountDisplay: React.FC<{ equipmentName: string }> = ({ equipmentName }) => {
|
|||
|
|
const [inventoryCount, setInventoryCount] = useState<string>('Проверка...');
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const fetchCount = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch('/api/office/inventory');
|
|||
|
|
if (response.ok) {
|
|||
|
|
const data = await response.json();
|
|||
|
|
const matchingItem = data.find((item: any) =>
|
|||
|
|
(item.name || '').toLowerCase() === (equipmentName || '').toLowerCase()
|
|||
|
|
);
|
|||
|
|
if (matchingItem) {
|
|||
|
|
setInventoryCount(`${matchingItem.quantity} ${matchingItem.unit || 'шт.'}`);
|
|||
|
|
} else {
|
|||
|
|
setInventoryCount('0 шт.');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Ошибка загрузки склада:', error);
|
|||
|
|
setInventoryCount('Ошибка');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
fetchCount();
|
|||
|
|
}, [equipmentName]);
|
|||
|
|
|
|||
|
|
return <span className="text-sm font-bold text-slate-800">{inventoryCount}</span>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const FacilityManagement: React.FC = () => {
|
|||
|
|
const [equipment, setEquipment] = useState<OfficeEquipment[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|||
|
|
const [showViewModal, setShowViewModal] = useState(false);
|
|||
|
|
const [showWriteOffModal, setShowWriteOffModal] = useState(false);
|
|||
|
|
const [showTransferModal, setShowTransferModal] = useState(false);
|
|||
|
|
const [selectedEquipment, setSelectedEquipment] = useState<OfficeEquipment | null>(null);
|
|||
|
|
const [writeOffFormData, setWriteOffFormData] = useState({
|
|||
|
|
reason: ''
|
|||
|
|
});
|
|||
|
|
const [employees, setEmployees] = useState<{ id: string; name: string; position?: string }[]>([]);
|
|||
|
|
const [assignedToDropdownOpen, setAssignedToDropdownOpen] = useState(false);
|
|||
|
|
const [assignedToFilter, setAssignedToFilter] = useState('');
|
|||
|
|
const [equipmentHistory, setEquipmentHistory] = useState<OfficeEquipmentHistoryItem[]>([]);
|
|||
|
|
const [transferToFilter, setTransferToFilter] = useState('');
|
|||
|
|
const [transferDropdownOpen, setTransferDropdownOpen] = useState(false);
|
|||
|
|
|
|||
|
|
const [formData, setFormData] = useState({
|
|||
|
|
name: '',
|
|||
|
|
type: 'pc' as 'pc' | 'laptop' | 'air_conditioner' | 'printer' | 'other',
|
|||
|
|
brand: '',
|
|||
|
|
model: '',
|
|||
|
|
serialNumber: '',
|
|||
|
|
assignedTo: '',
|
|||
|
|
purchaseDate: '',
|
|||
|
|
warrantyUntil: '',
|
|||
|
|
condition: 'good' as 'good' | 'fair' | 'poor',
|
|||
|
|
notes: '',
|
|||
|
|
nextMaintenanceDate: ''
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchEquipment();
|
|||
|
|
fetchEmployees();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (showViewModal && selectedEquipment?.id) {
|
|||
|
|
fetchEquipmentHistory(selectedEquipment.id);
|
|||
|
|
}
|
|||
|
|
}, [showViewModal, selectedEquipment?.id]);
|
|||
|
|
|
|||
|
|
const fetchEmployees = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch('/api/employees');
|
|||
|
|
if (response.ok) {
|
|||
|
|
const data = await response.json();
|
|||
|
|
const activeEmployees = data
|
|||
|
|
.filter((emp: Employee) => !emp.status || emp.status === 'active')
|
|||
|
|
.map((emp: Employee) => ({
|
|||
|
|
id: emp.id,
|
|||
|
|
name: emp.name,
|
|||
|
|
position: emp.position || ''
|
|||
|
|
}))
|
|||
|
|
.sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
|
|||
|
|
setEmployees(activeEmployees);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Ошибка загрузки сотрудников:', error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fetchEquipment = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch('/api/office/equipment');
|
|||
|
|
if (response.ok) {
|
|||
|
|
const data = await response.json();
|
|||
|
|
// Преобразуем snake_case в camelCase
|
|||
|
|
const normalizedData = data.map((eq: any) => ({
|
|||
|
|
id: eq.id,
|
|||
|
|
name: eq.name || '',
|
|||
|
|
type: eq.type || 'other',
|
|||
|
|
brand: eq.brand,
|
|||
|
|
model: eq.model,
|
|||
|
|
serialNumber: eq.serial_number || eq.serialNumber || '',
|
|||
|
|
assignedTo: eq.assigned_to || eq.assignedTo,
|
|||
|
|
purchaseDate: eq.purchase_date || eq.purchaseDate,
|
|||
|
|
warrantyUntil: eq.warranty_until || eq.warrantyUntil,
|
|||
|
|
nextMaintenanceDate: eq.next_maintenance_date || eq.nextMaintenanceDate,
|
|||
|
|
condition: eq.condition || 'good',
|
|||
|
|
notes: eq.notes,
|
|||
|
|
createdAt: eq.created_at,
|
|||
|
|
updatedAt: eq.updated_at
|
|||
|
|
}));
|
|||
|
|
setEquipment(normalizedData);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Ошибка загрузки оборудования:', error);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleCreateEquipment = async () => {
|
|||
|
|
try {
|
|||
|
|
if (!formData.name || !formData.name.trim()) {
|
|||
|
|
alert('Пожалуйста, укажите название оборудования');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await authFetch('/api/office/equipment', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
name: formData.name,
|
|||
|
|
type: formData.type,
|
|||
|
|
brand: formData.brand || null,
|
|||
|
|
model: formData.model || null,
|
|||
|
|
serial_number: formData.serialNumber || null,
|
|||
|
|
assigned_to: formData.assignedTo || null,
|
|||
|
|
purchase_date: formData.purchaseDate || null,
|
|||
|
|
warranty_until: formData.warrantyUntil || null,
|
|||
|
|
next_maintenance_date: formData.nextMaintenanceDate || null,
|
|||
|
|
condition: formData.condition || 'good',
|
|||
|
|
notes: formData.notes || null
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
const newEquipment = await response.json();
|
|||
|
|
// Преобразуем snake_case в camelCase
|
|||
|
|
const normalizedEquipment = {
|
|||
|
|
id: newEquipment.id,
|
|||
|
|
name: newEquipment.name || formData.name,
|
|||
|
|
type: newEquipment.type || formData.type,
|
|||
|
|
brand: newEquipment.brand || formData.brand,
|
|||
|
|
model: newEquipment.model || formData.model,
|
|||
|
|
serialNumber: newEquipment.serial_number || formData.serialNumber || '',
|
|||
|
|
assignedTo: newEquipment.assigned_to || formData.assignedTo,
|
|||
|
|
purchaseDate: newEquipment.purchase_date || formData.purchaseDate,
|
|||
|
|
warrantyUntil: newEquipment.warranty_until || formData.warrantyUntil,
|
|||
|
|
nextMaintenanceDate: newEquipment.next_maintenance_date || formData.nextMaintenanceDate,
|
|||
|
|
condition: newEquipment.condition || formData.condition,
|
|||
|
|
notes: newEquipment.notes || formData.notes,
|
|||
|
|
createdAt: newEquipment.created_at,
|
|||
|
|
updatedAt: newEquipment.updated_at
|
|||
|
|
};
|
|||
|
|
setEquipment([normalizedEquipment, ...equipment]);
|
|||
|
|
setShowCreateModal(false);
|
|||
|
|
setFormData({
|
|||
|
|
name: '',
|
|||
|
|
type: 'pc',
|
|||
|
|
brand: '',
|
|||
|
|
model: '',
|
|||
|
|
serialNumber: '',
|
|||
|
|
assignedTo: '',
|
|||
|
|
purchaseDate: '',
|
|||
|
|
warrantyUntil: '',
|
|||
|
|
condition: 'good',
|
|||
|
|
notes: '',
|
|||
|
|
nextMaintenanceDate: ''
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
|
|||
|
|
alert(`Ошибка создания оборудования: ${errorData.error || 'Неизвестная ошибка'}`);
|
|||
|
|
}
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('Ошибка создания оборудования:', error);
|
|||
|
|
alert(`Ошибка создания оборудования: ${error.message || 'Неизвестная ошибка'}`);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fetchEquipmentHistory = async (equipmentId: number) => {
|
|||
|
|
try {
|
|||
|
|
const response = await authFetch(`/api/office/equipment/${equipmentId}/history`);
|
|||
|
|
if (response.ok) {
|
|||
|
|
const data = await response.json();
|
|||
|
|
setEquipmentHistory(Array.isArray(data) ? data : []);
|
|||
|
|
} else {
|
|||
|
|
setEquipmentHistory([]);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Ошибка загрузки истории:', error);
|
|||
|
|
setEquipmentHistory([]);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleCheckEquipment = (eq: OfficeEquipment) => {
|
|||
|
|
setSelectedEquipment(eq);
|
|||
|
|
setFormData({
|
|||
|
|
name: eq.name || '',
|
|||
|
|
type: eq.type || 'other',
|
|||
|
|
brand: eq.brand || '',
|
|||
|
|
model: eq.model || '',
|
|||
|
|
serialNumber: eq.serialNumber || '',
|
|||
|
|
assignedTo: eq.assignedTo || '',
|
|||
|
|
purchaseDate: eq.purchaseDate || '',
|
|||
|
|
warrantyUntil: eq.warrantyUntil || '',
|
|||
|
|
condition: eq.condition || 'good',
|
|||
|
|
notes: eq.notes || '',
|
|||
|
|
nextMaintenanceDate: eq.nextMaintenanceDate || ''
|
|||
|
|
});
|
|||
|
|
setShowViewModal(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleUpdateEquipment = async () => {
|
|||
|
|
if (!selectedEquipment) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (!formData.name || !formData.name.trim()) {
|
|||
|
|
alert('Пожалуйста, укажите название оборудования');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await authFetch(`/api/office/equipment/${selectedEquipment.id}`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
name: formData.name,
|
|||
|
|
type: formData.type,
|
|||
|
|
brand: formData.brand || null,
|
|||
|
|
model: formData.model || null,
|
|||
|
|
serialNumber: formData.serialNumber || null,
|
|||
|
|
purchaseDate: formData.purchaseDate || null,
|
|||
|
|
warrantyUntil: formData.warrantyUntil || null,
|
|||
|
|
nextMaintenanceDate: formData.nextMaintenanceDate || null,
|
|||
|
|
condition: formData.condition || 'good',
|
|||
|
|
notes: formData.notes || null
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
fetchEquipment();
|
|||
|
|
setShowViewModal(false);
|
|||
|
|
setSelectedEquipment(null);
|
|||
|
|
alert('Оборудование успешно обновлено');
|
|||
|
|
} else {
|
|||
|
|
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
|
|||
|
|
alert(`Ошибка обновления: ${errorData.error || 'Неизвестная ошибка'}`);
|
|||
|
|
}
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('Ошибка обновления оборудования:', error);
|
|||
|
|
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getEquipmentIcon = (type: string) => {
|
|||
|
|
switch (type) {
|
|||
|
|
case 'laptop':
|
|||
|
|
return <Monitor className="w-6 h-6"/>;
|
|||
|
|
case 'air_conditioner':
|
|||
|
|
return <Coffee className="w-6 h-6"/>;
|
|||
|
|
default:
|
|||
|
|
return <Package className="w-6 h-6"/>;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTypeLabel = (type: string) => {
|
|||
|
|
const labels = {
|
|||
|
|
pc: 'ПК',
|
|||
|
|
laptop: 'Ноутбук',
|
|||
|
|
air_conditioner: 'Кондиционер',
|
|||
|
|
printer: 'Принтер',
|
|||
|
|
other: 'Другое'
|
|||
|
|
};
|
|||
|
|
return labels[type as keyof typeof labels] || type;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Основные средства (ОС)</h3>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowCreateModal(true)}
|
|||
|
|
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors flex items-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" />
|
|||
|
|
Создать
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="text-center py-8 text-slate-500">Загрузка...</div>
|
|||
|
|
) : equipment && equipment.length > 0 ? (
|
|||
|
|
<div className="grid grid-cols-1 gap-3">
|
|||
|
|
{equipment.map(asset => {
|
|||
|
|
if (!asset || !asset.id) return null;
|
|||
|
|
const needsMaintenance = asset.nextMaintenanceDate &&
|
|||
|
|
new Date(asset.nextMaintenanceDate) <= new Date();
|
|||
|
|
return (
|
|||
|
|
<div key={asset.id} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex justify-between items-center group hover:border-primary-300 transition-all">
|
|||
|
|
<div className="flex items-center gap-4">
|
|||
|
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors border border-slate-100">
|
|||
|
|
{getEquipmentIcon(asset.type)}
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<h4 className="font-bold text-slate-800 text-sm">{asset.name}</h4>
|
|||
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|||
|
|
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">
|
|||
|
|
{asset.serialNumber ? `S/N: ${asset.serialNumber}` : getTypeLabel(asset.type)}
|
|||
|
|
</p>
|
|||
|
|
<span className="w-1 h-1 rounded-full bg-slate-300"/>
|
|||
|
|
<p className="text-[10px] text-primary-600 font-black">{asset.assignedTo || 'Свободен'}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right flex items-center gap-3">
|
|||
|
|
<div>
|
|||
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded uppercase tracking-tighter ${
|
|||
|
|
asset.condition === 'good' ? 'bg-emerald-50 text-emerald-600' :
|
|||
|
|
asset.condition === 'fair' ? 'bg-amber-50 text-amber-600' :
|
|||
|
|
'bg-red-50 text-red-600'
|
|||
|
|
}`}>
|
|||
|
|
{asset.condition === 'good' ? 'Исправен' : asset.condition === 'fair' ? 'Требует ТО' : 'Неисправен'}
|
|||
|
|
</span>
|
|||
|
|
{needsMaintenance && (
|
|||
|
|
<span className="block text-[8px] font-black text-red-500 uppercase tracking-tighter mt-1">
|
|||
|
|
Требуется ТО
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleCheckEquipment(asset)}
|
|||
|
|
className="p-1.5 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors border border-blue-100"
|
|||
|
|
title="Открыть карточку"
|
|||
|
|
>
|
|||
|
|
<CheckCircle2 className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setSelectedEquipment(asset);
|
|||
|
|
setWriteOffFormData({ reason: '' });
|
|||
|
|
setShowWriteOffModal(true);
|
|||
|
|
}}
|
|||
|
|
className="p-1.5 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors border border-red-100"
|
|||
|
|
title="Списать оборудование"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
<button className="block p-1.5 bg-slate-50 rounded-lg text-slate-400 hover:text-primary-600 transition-colors border border-slate-100">
|
|||
|
|
<QrCode className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}).filter(Boolean)}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-8 text-slate-500">Нет оборудования</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Create Equipment Modal */}
|
|||
|
|
{showCreateModal && (
|
|||
|
|
<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-bold text-slate-800">Добавить оборудование</h3>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowCreateModal(false)}
|
|||
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|||
|
|
>
|
|||
|
|
<X className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Название *</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.name}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
placeholder="Например: Ноутбук Dell"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Тип *</label>
|
|||
|
|
<select
|
|||
|
|
value={formData.type}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="pc">ПК</option>
|
|||
|
|
<option value="laptop">Ноутбук</option>
|
|||
|
|
<option value="air_conditioner">Кондиционер</option>
|
|||
|
|
<option value="printer">Принтер</option>
|
|||
|
|
<option value="other">Другое</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Бренд</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.brand}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Модель</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.model}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Серийный номер</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.serialNumber}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="relative">
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Назначено</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={assignedToDropdownOpen ? assignedToFilter : formData.assignedTo}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
setAssignedToFilter(e.target.value);
|
|||
|
|
setAssignedToDropdownOpen(true);
|
|||
|
|
}}
|
|||
|
|
onFocus={() => {
|
|||
|
|
setAssignedToFilter(formData.assignedTo);
|
|||
|
|
setAssignedToDropdownOpen(true);
|
|||
|
|
}}
|
|||
|
|
onBlur={() => {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
setAssignedToDropdownOpen(false);
|
|||
|
|
setAssignedToFilter(formData.assignedTo);
|
|||
|
|
}, 200);
|
|||
|
|
}}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
placeholder="Начните вводить имя..."
|
|||
|
|
/>
|
|||
|
|
{assignedToDropdownOpen && (
|
|||
|
|
<div className="absolute z-10 mt-1 w-full bg-white border border-slate-300 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
setFormData({ ...formData, assignedTo: '' });
|
|||
|
|
setAssignedToFilter('');
|
|||
|
|
setAssignedToDropdownOpen(false);
|
|||
|
|
}}
|
|||
|
|
className="w-full px-3 py-2 text-left text-sm text-slate-500 hover:bg-slate-100"
|
|||
|
|
>
|
|||
|
|
— Не назначено —
|
|||
|
|
</button>
|
|||
|
|
{employees
|
|||
|
|
.filter((emp) =>
|
|||
|
|
emp.name.toLowerCase().includes(assignedToFilter.toLowerCase()) ||
|
|||
|
|
(emp.position && emp.position.toLowerCase().includes(assignedToFilter.toLowerCase()))
|
|||
|
|
)
|
|||
|
|
.map((emp) => (
|
|||
|
|
<button
|
|||
|
|
key={emp.id}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
setFormData({ ...formData, assignedTo: emp.name });
|
|||
|
|
setAssignedToFilter(emp.name);
|
|||
|
|
setAssignedToDropdownOpen(false);
|
|||
|
|
}}
|
|||
|
|
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
|||
|
|
>
|
|||
|
|
{emp.name}{emp.position ? ` — ${emp.position}` : ''}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
{formData.assignedTo && !employees.some((e) => e.name === formData.assignedTo) &&
|
|||
|
|
(formData.assignedTo.toLowerCase().includes(assignedToFilter.toLowerCase()) || !assignedToFilter) && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
setAssignedToFilter(formData.assignedTo);
|
|||
|
|
setAssignedToDropdownOpen(false);
|
|||
|
|
}}
|
|||
|
|
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-100 border-t border-slate-200"
|
|||
|
|
>
|
|||
|
|
{formData.assignedTo}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{employees.length === 0 && (
|
|||
|
|
<p className="text-xs text-slate-400 mt-1">Загрузка списка сотрудников...</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Дата покупки</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.purchaseDate}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, purchaseDate: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Гарантия до</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.warrantyUntil}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, warrantyUntil: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Следующее ТО</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.nextMaintenanceDate}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, nextMaintenanceDate: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Состояние *</label>
|
|||
|
|
<select
|
|||
|
|
value={formData.condition}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, condition: e.target.value as any })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="good">Исправен</option>
|
|||
|
|
<option value="fair">Требует ТО</option>
|
|||
|
|
<option value="poor">Неисправен</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
|
|||
|
|
<textarea
|
|||
|
|
value={formData.notes}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
rows={3}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex gap-3 pt-4">
|
|||
|
|
<button
|
|||
|
|
onClick={handleCreateEquipment}
|
|||
|
|
disabled={!formData.name}
|
|||
|
|
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
Создать
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowCreateModal(false)}
|
|||
|
|
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
|
|||
|
|
>
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* View/Edit Equipment Modal */}
|
|||
|
|
{showViewModal && selectedEquipment && (
|
|||
|
|
<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-bold text-slate-800">Карточка оборудования</h3>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowViewModal(false);
|
|||
|
|
setSelectedEquipment(null);
|
|||
|
|
}}
|
|||
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|||
|
|
>
|
|||
|
|
<X className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Название *</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.name}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Тип *</label>
|
|||
|
|
<select
|
|||
|
|
value={formData.type}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="pc">ПК</option>
|
|||
|
|
<option value="laptop">Ноутбук</option>
|
|||
|
|
<option value="air_conditioner">Кондиционер</option>
|
|||
|
|
<option value="printer">Принтер</option>
|
|||
|
|
<option value="other">Другое</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Бренд</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.brand}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Модель</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.model}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Серийный номер</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formData.serialNumber}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Закреплено за</label>
|
|||
|
|
<div className="flex gap-2 items-center">
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
readOnly
|
|||
|
|
value={formData.assignedTo || '— Свободен —'}
|
|||
|
|
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm bg-slate-50"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setShowTransferModal(true)}
|
|||
|
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-primary-100 text-primary-700 hover:bg-primary-200 transition-colors"
|
|||
|
|
>
|
|||
|
|
<ArrowRightLeft className="w-4 h-4" />
|
|||
|
|
Перемещение
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Дата покупки</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.purchaseDate}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, purchaseDate: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Гарантия до</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.warrantyUntil}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, warrantyUntil: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Следующее ТО</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={formData.nextMaintenanceDate}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, nextMaintenanceDate: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Состояние *</label>
|
|||
|
|
<select
|
|||
|
|
value={formData.condition}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, condition: e.target.value as any })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="good">Исправен</option>
|
|||
|
|
<option value="fair">Требует ТО</option>
|
|||
|
|
<option value="poor">Неисправен</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
|
|||
|
|
<textarea
|
|||
|
|
value={formData.notes}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
rows={3}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="border-t border-slate-200 pt-4">
|
|||
|
|
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-700 mb-2">
|
|||
|
|
<History className="w-4 h-4" />
|
|||
|
|
История
|
|||
|
|
</h4>
|
|||
|
|
<div className="max-h-40 overflow-y-auto space-y-2">
|
|||
|
|
{equipmentHistory.length === 0 && (
|
|||
|
|
<p className="text-xs text-slate-400">Нет записей</p>
|
|||
|
|
)}
|
|||
|
|
{equipmentHistory.map((item) => {
|
|||
|
|
const d = item.event_date ? new Date(item.event_date).toLocaleDateString('ru-RU') : '';
|
|||
|
|
if (item.event_type === 'purchase') {
|
|||
|
|
return <div key={item.id} className="text-xs text-slate-600 py-1 border-b border-slate-100">Закупка — {d}</div>;
|
|||
|
|
}
|
|||
|
|
if (item.event_type === 'issue') {
|
|||
|
|
return <div key={item.id} className="text-xs text-slate-600 py-1 border-b border-slate-100">Выдача — {d} → {item.assigned_to || ''}</div>;
|
|||
|
|
}
|
|||
|
|
if (item.event_type === 'transfer') {
|
|||
|
|
return <div key={item.id} className="text-xs text-slate-600 py-1 border-b border-slate-100">Перемещение — {d}: {item.assigned_from || '—'} → {item.assigned_to || '—'}</div>;
|
|||
|
|
}
|
|||
|
|
if (item.event_type === 'repair') {
|
|||
|
|
return <div key={item.id} className="text-xs text-slate-600 py-1 border-b border-slate-100">На ремонте — {d}. {item.reason ? `Причина: ${item.reason}` : ''}</div>;
|
|||
|
|
}
|
|||
|
|
if (item.event_type === 'write_off') {
|
|||
|
|
return <div key={item.id} className="text-xs text-red-600 py-1 border-b border-slate-100">Списание — {d}. {item.reason || ''}</div>;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex gap-3 pt-4">
|
|||
|
|
<button
|
|||
|
|
onClick={handleUpdateEquipment}
|
|||
|
|
disabled={!formData.name}
|
|||
|
|
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
Сохранить
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowViewModal(false);
|
|||
|
|
setSelectedEquipment(null);
|
|||
|
|
}}
|
|||
|
|
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
|
|||
|
|
>
|
|||
|
|
Закрыть
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Transfer Modal */}
|
|||
|
|
{showTransferModal && selectedEquipment && (
|
|||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[60] p-4">
|
|||
|
|
<div className="bg-white rounded-2xl p-6 max-w-sm w-full">
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Перемещение</h3>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowTransferModal(false);
|
|||
|
|
setTransferToFilter('');
|
|||
|
|
setTransferDropdownOpen(false);
|
|||
|
|
}}
|
|||
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|||
|
|
>
|
|||
|
|
<X className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-slate-600 mb-2">{selectedEquipment.name}</p>
|
|||
|
|
<p className="text-xs text-slate-500 mb-3">Сейчас: {formData.assignedTo || '— Свободен —'}</p>
|
|||
|
|
<div className="relative mb-4">
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Передать</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={transferDropdownOpen ? transferToFilter : (transferToFilter || '')}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
setTransferToFilter(e.target.value);
|
|||
|
|
setTransferDropdownOpen(true);
|
|||
|
|
}}
|
|||
|
|
onFocus={() => setTransferDropdownOpen(true)}
|
|||
|
|
onBlur={() => setTimeout(() => setTransferDropdownOpen(false), 200)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
placeholder="Начните вводить имя..."
|
|||
|
|
/>
|
|||
|
|
{transferDropdownOpen && (
|
|||
|
|
<div
|
|||
|
|
className="absolute z-10 mt-1 w-full bg-white border border-slate-300 rounded-lg shadow-lg max-h-40 overflow-y-auto"
|
|||
|
|
onMouseDown={(e) => e.preventDefault()}
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onMouseDown={(e) => e.preventDefault()}
|
|||
|
|
onClick={async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await authFetch(`/api/office/equipment/${selectedEquipment.id}/transfer`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ assignedTo: '' })
|
|||
|
|
});
|
|||
|
|
if (res.ok) {
|
|||
|
|
const updated = await res.json();
|
|||
|
|
setFormData((f) => ({ ...f, assignedTo: updated.assigned_to ?? '' }));
|
|||
|
|
setSelectedEquipment((e) => e ? { ...e, assignedTo: updated.assigned_to ?? '' } : null);
|
|||
|
|
fetchEquipment();
|
|||
|
|
fetchEquipmentHistory(selectedEquipment.id);
|
|||
|
|
setShowTransferModal(false);
|
|||
|
|
setTransferToFilter('');
|
|||
|
|
} else {
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
alert(data?.error || `Ошибка перемещения (${res.status})`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(err);
|
|||
|
|
alert('Ошибка перемещения');
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
className="w-full px-3 py-2 text-left text-sm text-slate-500 hover:bg-slate-100"
|
|||
|
|
>
|
|||
|
|
— Свободен (снять с сотрудника) —
|
|||
|
|
</button>
|
|||
|
|
{employees
|
|||
|
|
.filter((emp) =>
|
|||
|
|
emp.name.toLowerCase().includes(transferToFilter.toLowerCase()) ||
|
|||
|
|
(emp.position && emp.position.toLowerCase().includes(transferToFilter.toLowerCase()))
|
|||
|
|
)
|
|||
|
|
.map((emp) => (
|
|||
|
|
<button
|
|||
|
|
key={emp.id}
|
|||
|
|
type="button"
|
|||
|
|
onMouseDown={(e) => e.preventDefault()}
|
|||
|
|
onClick={async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await authFetch(`/api/office/equipment/${selectedEquipment.id}/transfer`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ assignedTo: emp.name })
|
|||
|
|
});
|
|||
|
|
if (res.ok) {
|
|||
|
|
const updated = await res.json();
|
|||
|
|
setFormData((f) => ({ ...f, assignedTo: updated.assigned_to ?? emp.name }));
|
|||
|
|
setSelectedEquipment((e) => e ? { ...e, assignedTo: updated.assigned_to ?? emp.name } : null);
|
|||
|
|
fetchEquipment();
|
|||
|
|
fetchEquipmentHistory(selectedEquipment.id);
|
|||
|
|
setShowTransferModal(false);
|
|||
|
|
setTransferToFilter('');
|
|||
|
|
} else {
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
alert(data?.error || `Ошибка перемещения (${res.status})`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(err);
|
|||
|
|
alert('Ошибка перемещения');
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
|||
|
|
>
|
|||
|
|
{emp.name}{emp.position ? ` — ${emp.position}` : ''}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Write Off Modal - Unified for Equipment and Inventory */}
|
|||
|
|
{showWriteOffModal && selectedEquipment && (
|
|||
|
|
<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-bold text-slate-800">Списать оборудование</h3>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowWriteOffModal(false);
|
|||
|
|
setSelectedEquipment(null);
|
|||
|
|
}}
|
|||
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|||
|
|
>
|
|||
|
|
<X className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Оборудование</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={selectedEquipment.name}
|
|||
|
|
disabled
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-slate-50 p-4 rounded-lg space-y-2">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<span className="text-sm text-slate-600">В имуществе:</span>
|
|||
|
|
<span className="text-sm font-bold text-slate-800">1 шт.</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<span className="text-sm text-slate-600">На складе:</span>
|
|||
|
|
<InventoryCountDisplay equipmentName={selectedEquipment.name} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Причина списания *</label>
|
|||
|
|
<textarea
|
|||
|
|
value={writeOffFormData.reason}
|
|||
|
|
onChange={(e) => setWriteOffFormData({ reason: e.target.value })}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|||
|
|
rows={3}
|
|||
|
|
placeholder="Укажите причину списания (порча, поломка, истечение срока годности, и т.д.)"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
|
|||
|
|
<p className="font-bold mb-1">⚠️ Внимание:</p>
|
|||
|
|
<p>При списании оборудование будет удалено из имущества и со склада одновременно.</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex gap-3 pt-4">
|
|||
|
|
<button
|
|||
|
|
onClick={async () => {
|
|||
|
|
if (!writeOffFormData.reason || !writeOffFormData.reason.trim()) {
|
|||
|
|
alert('Укажите причину списания');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const writeOffNote = `Списано. Причина: ${writeOffFormData.reason}. Дата: ${new Date().toLocaleDateString('ru-RU')}`;
|
|||
|
|
const today = new Date().toISOString().slice(0, 10);
|
|||
|
|
|
|||
|
|
await authFetch(`/api/office/equipment/${selectedEquipment.id}/history`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
event_type: 'write_off',
|
|||
|
|
event_date: today,
|
|||
|
|
reason: writeOffFormData.reason.trim()
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const existingEqNotes = selectedEquipment.notes || '';
|
|||
|
|
const updatedEqNotes = existingEqNotes ? `${existingEqNotes}\n${writeOffNote}` : writeOffNote;
|
|||
|
|
|
|||
|
|
await authFetch(`/api/office/equipment/${selectedEquipment.id}`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
condition: 'poor',
|
|||
|
|
notes: updatedEqNotes
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Ищем и списываем со склада
|
|||
|
|
try {
|
|||
|
|
const inventoryResponse = await authFetch('/api/office/inventory');
|
|||
|
|
if (inventoryResponse.ok) {
|
|||
|
|
const inventoryData = await inventoryResponse.json();
|
|||
|
|
const matchingInventory = inventoryData.find((item: any) =>
|
|||
|
|
(item.name || '').toLowerCase() === (selectedEquipment.name || '').toLowerCase()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (matchingInventory) {
|
|||
|
|
const newQuantity = Math.max(0, (parseFloat(matchingInventory.quantity) || 0) - 1);
|
|||
|
|
const existingInvNotes = matchingInventory.notes || '';
|
|||
|
|
const updatedInvNotes = existingInvNotes ? `${existingInvNotes}\n${writeOffNote}` : writeOffNote;
|
|||
|
|
|
|||
|
|
await authFetch(`/api/office/inventory/${matchingInventory.id}`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
quantity: newQuantity,
|
|||
|
|
notes: updatedInvNotes
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (invError) {
|
|||
|
|
console.error('Ошибка обновления склада:', invError);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fetchEquipment();
|
|||
|
|
setShowWriteOffModal(false);
|
|||
|
|
setSelectedEquipment(null);
|
|||
|
|
alert('Оборудование списано из имущества и со склада');
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('Ошибка списания:', error);
|
|||
|
|
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
disabled={!writeOffFormData.reason || !writeOffFormData.reason.trim()}
|
|||
|
|
className="flex-1 bg-red-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-red-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-4 h-4" />
|
|||
|
|
Списать
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowWriteOffModal(false);
|
|||
|
|
setSelectedEquipment(null);
|
|||
|
|
}}
|
|||
|
|
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
|
|||
|
|
>
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|