Files
mkd/components/office/FacilityManagement.tsx
2026-02-04 00:17:04 +05:00

1069 lines
64 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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>
);
};