Files
mkd/components/office/FacilityManagement.tsx

1069 lines
64 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};