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