Files
mkd/components/office/SupplyRegistry.tsx

3098 lines
183 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { authFetch } from '../../services/apiClient';
import { OfficeRequest, OfficeInventoryItem, Employee } from '../../types';
import { CURRENT_USER_MOCK } from '../../constants';
import { ShoppingCart, Package, AlertTriangle, Search, Plus, CheckCircle2, X, Edit, Warehouse, Monitor, Save, FileText, Trash2, Copy, ClipboardList, Bell, CheckSquare, Receipt } from 'lucide-react';
interface Props {
requests: OfficeRequest[];
}
export const SupplyRegistry: React.FC<Props> = ({ requests: initialRequests }) => {
const [view, setView] = useState<'requests' | 'orders' | 'inventory'>('requests');
const [search, setSearch] = useState('');
const [requests, setRequests] = useState<OfficeRequest[]>(initialRequests);
const [orders, setOrders] = useState<any[]>([]);
const [inventory, setInventory] = useState<OfficeInventoryItem[]>([]);
const [equipment, setEquipment] = useState<any[]>([]);
const [employees, setEmployees] = useState<{ id: string; name: string; position?: string }[]>([]);
const [equipmentAssignedDropdownOpen, setEquipmentAssignedDropdownOpen] = useState(false);
const [equipmentAssignedFilter, setEquipmentAssignedFilter] = useState('');
const [loading, setLoading] = useState(true);
// Функция для проверки, является ли товар оборудованием
const isEquipment = (request: OfficeRequest): boolean => {
const itemName = (request.itemName || '').toLowerCase();
return request.category === 'equipment' ||
itemName.includes('компьютер') ||
itemName.includes('ноутбук') ||
itemName.includes('микроволновка') ||
itemName.includes('принтер') ||
itemName.includes('монитор') ||
itemName.includes('кондиционер') ||
itemName.includes('кондер') ||
itemName.includes('телефон') ||
itemName.includes('сканер');
};
const [showCreateRequestModal, setShowCreateRequestModal] = useState(false);
const [showCreateOrderModal, setShowCreateOrderModal] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedRequestsForOrder, setSelectedRequestsForOrder] = useState<Set<number | string>>(new Set());
const [showEditModal, setShowEditModal] = useState(false);
const [showMoveToInventoryModal, setShowMoveToInventoryModal] = useState(false);
const [showAddToEquipmentModal, setShowAddToEquipmentModal] = useState(false);
const [showIssueFromInventoryModal, setShowIssueFromInventoryModal] = useState(false);
const [showWriteOffModal, setShowWriteOffModal] = useState(false);
const [writeOffSource, setWriteOffSource] = useState<'inventory' | 'equipment'>('inventory');
const [selectedRequest, setSelectedRequest] = useState<OfficeRequest | null>(null);
const [selectedInventoryItem, setSelectedInventoryItem] = useState<OfficeInventoryItem | null>(null);
const [selectedEquipmentForWriteOff, setSelectedEquipmentForWriteOff] = useState<any | null>(null);
// Форма редактирования заявки
const [editFormData, setEditFormData] = useState({
amount: 0,
status: 'new' as 'new' | 'approved' | 'ordered' | 'received' | 'canceled',
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
notes: ''
});
// Форма переноса на склад
const [inventoryFormData, setInventoryFormData] = useState({
quantity: 0,
location: '',
notes: ''
});
// Форма выдачи со склада
const [issueFormData, setIssueFormData] = useState({
quantity: 0,
requestId: '',
notes: ''
});
// Форма списания со склада
const [writeOffFormData, setWriteOffFormData] = useState({
quantity: 0,
reason: ''
});
// Форма добавления в имущество
const [equipmentFormData, setEquipmentFormData] = useState({
type: 'other' as 'pc' | 'laptop' | 'air_conditioner' | 'printer' | 'other',
brand: '',
model: '',
serialNumber: '',
assignedTo: '',
purchaseDate: '',
warrantyUntil: '',
condition: 'good' as 'good' | 'fair' | 'poor',
notes: ''
});
// Форма создания заявки (таблица для нескольких позиций)
const [requestItems, setRequestItems] = useState<Array<{
id: string;
category: string;
itemName: string;
quantity: number;
unit: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
notes: string;
}>>([{
id: '1',
category: 'stationery',
itemName: '',
quantity: 1,
unit: 'шт.',
priority: 'medium',
notes: ''
}]);
useEffect(() => {
fetchRequests();
fetchInventory();
fetchOrders();
fetchEquipmentForWriteOff();
fetchEmployees();
// Используем начальные данные, если API еще не загрузил
if (initialRequests.length > 0 && requests.length === 0) {
setRequests(initialRequests);
}
}, []);
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 fetchOrders = async () => {
try {
const response = await authFetch('/api/office/orders');
if (response.ok) {
const data = await response.json();
setOrders(data);
}
} catch (error) {
console.error('Ошибка загрузки заказов:', error);
}
};
const fetchEquipmentForWriteOff = async () => {
try {
const data = await fetchEquipment();
setEquipment(data);
} catch (error) {
console.error('Ошибка загрузки оборудования:', error);
}
};
const fetchRequests = async () => {
try {
const response = await authFetch('/api/office/supply-requests');
if (response.ok) {
const data = await response.json();
// Преобразуем snake_case в camelCase
const normalizedData = data.map((req: any) => ({
id: req.id,
requesterName: req.requester_name || req.requesterName || '',
category: req.category || '',
itemName: req.item_name || req.itemName || '',
quantity: req.quantity || 1,
issuedQuantity: req.issued_quantity || req.issuedQuantity || 0,
unit: req.unit || 'шт.',
amount: parseFloat(req.amount) || 0,
priority: req.priority || 'medium',
status: req.status || 'new',
approvedBy: req.approved_by || req.approvedBy,
approvedAt: req.approved_at || req.approvedAt,
orderedAt: req.ordered_at || req.orderedAt,
receivedAt: req.received_at || req.receivedAt,
notes: req.notes,
date: req.created_at || req.date,
createdAt: req.created_at,
updatedAt: req.updated_at
}));
setRequests(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки заявок:', error);
} finally {
setLoading(false);
}
};
const fetchInventory = async () => {
try {
const response = await authFetch('/api/office/inventory');
if (response.ok) {
const data = await response.json();
// Преобразуем snake_case в camelCase
const normalizedData = data.map((item: any) => ({
id: item.id,
name: item.name || '',
category: item.category,
quantity: parseFloat(item.quantity) || 0,
unit: item.unit || 'шт.',
minThreshold: parseFloat(item.min_threshold) || parseFloat(item.minThreshold) || 0,
lastRestock: item.last_restock || item.lastRestock,
lastRestockBy: item.last_restock_by || item.lastRestockBy,
location: item.location,
notes: item.notes,
createdAt: item.created_at,
updatedAt: item.updated_at
}));
setInventory(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки склада:', error);
}
};
const fetchEquipment = async () => {
try {
const response = await authFetch('/api/office/equipment');
if (response.ok) {
const data = await response.json();
return 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,
condition: eq.condition || 'good',
notes: eq.notes
}));
}
return [];
} catch (error) {
console.error('Ошибка загрузки оборудования:', error);
return [];
}
};
const handleToggleRequestSelection = (requestId: number | string) => {
const newSelected = new Set(selectedRequestsForOrder);
if (newSelected.has(requestId)) {
newSelected.delete(requestId);
} else {
newSelected.add(requestId);
}
setSelectedRequestsForOrder(newSelected);
};
const handleSelectCategoryRequests = (category: string) => {
const categoryRequests = requests
.filter(req => req.status === 'new' || req.status === 'approved')
.filter(req => req.category === category)
.map(req => req.id);
const newSelected = new Set(selectedRequestsForOrder);
categoryRequests.forEach(id => newSelected.add(id));
setSelectedRequestsForOrder(newSelected);
setSelectedCategory(category);
};
const handleCreateOrder = async () => {
if (selectedRequestsForOrder.size === 0) {
alert('Выберите хотя бы одну заявку для заказа');
return;
}
try {
const selectedRequests = requests.filter(req => selectedRequestsForOrder.has(req.id));
// Цены теперь удобно проставлять прямо в окне заказа,
// поэтому на этапе создания заказа не блокируем процесс по отсутствию суммы.
const totalAmount = selectedRequests.reduce((sum, req) => sum + (req.amount || 0), 0);
// Создаем заказ (пока просто обновляем статусы заявок)
// В будущем можно создать отдельную таблицу orders
const orderNumber = `ORD-${Date.now()}`;
// Обновляем статусы заявок на "ordered" и добавляем order_id
for (const req of selectedRequests) {
await authFetch(`/api/office/supply-requests/${req.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'ordered',
ordered_at: new Date().toISOString(),
notes: (req.notes || '') + `\n[Заказ: ${orderNumber}]`
})
});
}
// Создаем счет на оплату (через API финансов)
const invoiceData = {
buildingId: 'office',
address: 'Офис',
contractorName: 'Поставщик',
serviceName: `Заказ офисных товаров ${orderNumber}`,
amount: totalAmount,
date: new Date().toISOString().split('T')[0],
status: 'pending_approval',
priority: 'medium',
closingDocsReceived: false
};
// Здесь можно вызвать API для создания счета
// await authFetch('/api/finance/invoices', { method: 'POST', ... });
const requestIds = selectedRequests.map(req => req.id);
// Создаем заказ через API
const response = await authFetch('/api/office/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `Заказ от ${new Date().toLocaleDateString('ru-RU')}`,
description: `Заказ включает ${selectedRequests.length} позиций`,
requestIds: requestIds,
createdBy: CURRENT_USER_MOCK.name || 'Система'
})
});
if (response.ok) {
const newOrder = await response.json();
alert(`Заказ ${newOrder.order_number} успешно создан`);
setSelectedRequestsForOrder(new Set());
setSelectedCategory(null);
setShowCreateOrderModal(false);
fetchOrders();
setView('orders'); // Переключаемся на вкладку заказов
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания заказа: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания заказа:', error);
alert(`Ошибка создания заказа: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleMarkAsReceived = async (requestId: number | string) => {
try {
const request = requests.find(r => r.id === requestId);
if (!request) {
alert('Заявка не найдена');
return;
}
// Обновляем статус заявки
const response = await authFetch(`/api/office/supply-requests/${requestId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'received',
received_at: new Date().toISOString()
})
});
if (response.ok) {
fetchRequests();
// Если это оборудование, открываем модальное окно для заполнения данных имущества
if (isEquipment(request)) {
handleOpenAddToEquipmentModal(request);
alert('Товар получен. Заполните данные для постановки на учет в имущество.');
} else {
alert('Товар получен. Сотрудник будет уведомлен.');
}
}
} catch (error) {
console.error('Ошибка обновления статуса:', error);
alert('Ошибка обновления статуса');
}
};
const handleAddRequestRow = () => {
setRequestItems([...requestItems, {
id: Date.now().toString(),
category: 'stationery',
itemName: '',
quantity: 1,
unit: 'шт.',
priority: 'medium',
notes: ''
}]);
};
const handleRemoveRequestRow = (id: string) => {
if (requestItems.length > 1) {
setRequestItems(requestItems.filter(item => item.id !== id));
}
};
const handleUpdateRequestItem = (id: string, field: string, value: any) => {
setRequestItems(requestItems.map(item =>
item.id === id ? { ...item, [field]: value } : item
));
};
const handleMarkAsCollected = async (requestId: number | string) => {
try {
const request = requests.find(r => r.id === requestId);
if (!request) {
alert('Заявка не найдена');
return;
}
// Если еще не выдано, выдаем полное количество из заявки
const issuedQty = request.issuedQuantity || 0;
const requestedQty = request.quantity || 0;
const toIssue = requestedQty - issuedQty;
// Обновляем статус заявки и количество выданного
const response = await authFetch(`/api/office/supply-requests/${requestId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'collected',
issued_quantity: requestedQty // Выдаем все, что было в заявке
})
});
if (response.ok) {
// Если это оборудование, обновляем карточку имущества - закрепляем за сотрудником
if (isEquipment(request)) {
try {
// Извлекаем ID оборудования из примечаний
const equipmentIdMatch = request.notes?.match(/\[Equipment ID: (\d+)\]/);
if (equipmentIdMatch) {
const equipmentId = equipmentIdMatch[1];
const equipmentResponse = await authFetch(`/api/office/equipment/${equipmentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assignedTo: request.requesterName
})
});
if (equipmentResponse.ok) {
alert('Заявка отмечена как "Забрал сотрудник". Оборудование закреплено за ' + request.requesterName);
} else {
alert('Заявка отмечена, но не удалось обновить карточку имущества');
}
} else {
// Если ID не найден, ищем по названию
const equipmentListResponse = await authFetch('/api/office/equipment');
if (equipmentListResponse.ok) {
const equipmentList = await equipmentListResponse.json();
const equipment = equipmentList.find((eq: any) =>
(eq.name || '').toLowerCase() === (request.itemName || '').toLowerCase()
);
if (equipment) {
await authFetch(`/api/office/equipment/${equipment.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assignedTo: request.requesterName
})
});
alert('Заявка отмечена как "Забрал сотрудник". Оборудование закреплено за ' + request.requesterName);
} else {
alert('Заявка отмечена как "Забрал сотрудник"');
}
} else {
alert('Заявка отмечена как "Забрал сотрудник"');
}
}
} catch (equipmentError) {
console.error('Ошибка обновления карточки имущества:', equipmentError);
alert('Заявка отмечена как "Забрал сотрудник"');
}
} else {
alert('Заявка отмечена как "Забрал сотрудник"');
}
fetchRequests();
}
} catch (error) {
console.error('Ошибка обновления статуса:', error);
alert('Ошибка обновления статуса');
}
};
const handleArchiveRequest = async (requestId: number | string) => {
try {
const response = await authFetch(`/api/office/supply-requests/${requestId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'archived'
})
});
if (response.ok) {
fetchRequests();
alert('Заявка перемещена в архив');
}
} catch (error) {
console.error('Ошибка архивирования:', error);
}
};
const handleCreateRequest = async () => {
try {
// Проверяем, что все обязательные поля заполнены
const invalidItems = requestItems.filter(item => !item.itemName || !item.itemName.trim());
if (invalidItems.length > 0) {
alert('Пожалуйста, заполните наименование товара во всех строках');
return;
}
// Создаем все заявки
const createdRequests = [];
for (const item of requestItems) {
if (!item.itemName || !item.itemName.trim()) continue;
const response = await authFetch('/api/office/supply-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requesterName: CURRENT_USER_MOCK.name,
category: item.category,
itemName: item.itemName,
quantity: item.quantity || 1,
unit: item.unit || 'шт.',
amount: 0,
priority: item.priority || 'medium',
notes: item.notes || null
})
});
if (response.ok) {
const newRequest = await response.json();
// Преобразуем snake_case в camelCase
const normalizedRequest = {
id: newRequest.id,
requesterName: newRequest.requester_name || CURRENT_USER_MOCK.name,
category: newRequest.category || item.category,
itemName: newRequest.item_name || item.itemName,
quantity: newRequest.quantity || item.quantity || 1,
issuedQuantity: newRequest.issued_quantity || newRequest.issuedQuantity || 0,
unit: newRequest.unit || item.unit || 'шт.',
amount: parseFloat(newRequest.amount) || 0,
priority: newRequest.priority || item.priority || 'medium',
status: newRequest.status || 'new',
approvedBy: newRequest.approved_by,
approvedAt: newRequest.approved_at,
orderedAt: newRequest.ordered_at,
receivedAt: newRequest.received_at,
notes: newRequest.notes,
date: newRequest.created_at ? new Date(newRequest.created_at).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
createdAt: newRequest.created_at,
updatedAt: newRequest.updated_at
};
createdRequests.push(normalizedRequest);
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания заявки: ${errorData.error || 'Неизвестная ошибка'}`);
return;
}
}
if (createdRequests.length > 0) {
setRequests([...createdRequests, ...requests]);
setShowCreateRequestModal(false);
setRequestItems([{
id: '1',
category: 'stationery',
itemName: '',
quantity: 1,
unit: 'шт.',
priority: 'medium',
notes: ''
}]);
alert(`Создано заявок: ${createdRequests.length}`);
}
} catch (error: any) {
console.error('Ошибка создания заявки:', error);
alert(`Ошибка создания заявки: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleCheckRequest = async (request: OfficeRequest) => {
// Проверка наличия на складе и возможность одобрения
const requestItemName = (request.itemName || '').toLowerCase();
const inventoryItem = inventory.find(item =>
(item.name || '').toLowerCase().includes(requestItemName)
);
if (inventoryItem) {
const available = inventoryItem.quantity >= (request.quantity || 1);
if (available) {
alert(`Товар "${request.itemName}" есть на складе (${inventoryItem.quantity} ${inventoryItem.unit}). Можно одобрить заявку.`);
} else {
alert(`Товар "${request.itemName}" есть на складе, но недостаточно (требуется: ${request.quantity || 1}, есть: ${inventoryItem.quantity} ${inventoryItem.unit}).`);
}
} else {
alert(`Товар "${request.itemName}" не найден на складе. Требуется заказ.`);
}
};
const handleApproveRequest = async (requestId: number | string) => {
try {
const response = await authFetch(`/api/office/supply-requests/${requestId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'approved',
approved_by: CURRENT_USER_MOCK.name
})
});
if (response.ok) {
fetchRequests();
}
} catch (error) {
console.error('Ошибка одобрения заявки:', error);
}
};
const handleOpenEditModal = (request: OfficeRequest) => {
setSelectedRequest(request);
setEditFormData({
amount: request.amount || 0,
status: request.status || 'new',
priority: request.priority || 'medium',
notes: request.notes || ''
});
setShowEditModal(true);
};
const handleSaveEdit = async () => {
if (!selectedRequest) return;
try {
const response = await authFetch(`/api/office/supply-requests/${selectedRequest.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: editFormData.amount,
status: editFormData.status,
priority: editFormData.priority,
notes: editFormData.notes,
approved_by: editFormData.status === 'approved' ? CURRENT_USER_MOCK.name : undefined
})
});
if (response.ok) {
fetchRequests();
setShowEditModal(false);
setSelectedRequest(null);
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка сохранения: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка сохранения заявки:', error);
alert(`Ошибка сохранения: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleOpenMoveToInventoryModal = (request: OfficeRequest) => {
setSelectedRequest(request);
const requestedQty = request.quantity || 1;
const issuedQty = request.issuedQuantity || 0;
const availableForWarehouse = requestedQty - issuedQty;
setInventoryFormData({
quantity: Math.max(0, availableForWarehouse),
location: '',
notes: ''
});
setShowMoveToInventoryModal(true);
};
const handleMoveToInventory = async () => {
if (!selectedRequest) return;
try {
// Создаем или обновляем запись на складе
const existingItem = inventory.find(item =>
(item.name || '').toLowerCase() === (selectedRequest.itemName || '').toLowerCase()
);
if (existingItem) {
// Обновляем существующий товар
const response = await authFetch(`/api/office/inventory/${existingItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quantity: (existingItem.quantity || 0) + inventoryFormData.quantity,
last_restock: new Date().toISOString().split('T')[0],
last_restock_by: CURRENT_USER_MOCK.name,
location: inventoryFormData.location || existingItem.location || null,
notes: inventoryFormData.notes || existingItem.notes || null
})
});
if (response.ok) {
// Обновляем статус заявки на "received"
await authFetch(`/api/office/supply-requests/${selectedRequest.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'received',
received_at: new Date().toISOString()
})
});
// Если это оборудование, создаем карточку в имуществе с закреплением за офис-менеджером
if (isEquipment(selectedRequest)) {
try {
const equipmentResponse = await authFetch('/api/office/equipment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: selectedRequest.itemName,
type: 'other',
assignedTo: CURRENT_USER_MOCK.name,
purchaseDate: new Date().toISOString().split('T')[0],
condition: 'good',
notes: `На складе. Создано из заявки #${selectedRequest.id}. Заказчик: ${selectedRequest.requesterName}`
})
});
if (equipmentResponse.ok) {
const equipment = await equipmentResponse.json();
// Сохраняем ID оборудования в примечаниях заявки
await authFetch(`/api/office/supply-requests/${selectedRequest.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: `${selectedRequest.notes || ''}\n[Equipment ID: ${equipment.id}]`.trim()
})
});
}
} catch (equipmentError) {
console.error('Ошибка создания карточки имущества:', equipmentError);
}
}
fetchRequests();
fetchInventory();
setShowMoveToInventoryModal(false);
setSelectedRequest(null);
alert('Товар успешно добавлен на склад' + (isEquipment(selectedRequest) ? '. Карточка имущества создана с закреплением за офис-менеджером.' : ''));
}
} else {
// Создаем новый товар
const response = await authFetch('/api/office/inventory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: selectedRequest.itemName,
category: selectedRequest.category || 'other',
quantity: inventoryFormData.quantity,
unit: selectedRequest.unit || 'шт.',
minThreshold: 0,
location: inventoryFormData.location || null,
notes: inventoryFormData.notes || null
})
});
if (response.ok) {
// Обновляем статус заявки на "received"
await authFetch(`/api/office/supply-requests/${selectedRequest.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'received',
received_at: new Date().toISOString()
})
});
// Если это оборудование, создаем карточку в имуществе с закреплением за офис-менеджером
if (isEquipment(selectedRequest)) {
try {
const equipmentResponse = await authFetch('/api/office/equipment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: selectedRequest.itemName,
type: 'other',
assignedTo: CURRENT_USER_MOCK.name,
purchaseDate: new Date().toISOString().split('T')[0],
condition: 'good',
notes: `На складе. Создано из заявки #${selectedRequest.id}. Заказчик: ${selectedRequest.requesterName}`
})
});
if (equipmentResponse.ok) {
const equipment = await equipmentResponse.json();
// Сохраняем ID оборудования в примечаниях заявки
await authFetch(`/api/office/supply-requests/${selectedRequest.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: `${selectedRequest.notes || ''}\n[Equipment ID: ${equipment.id}]`.trim()
})
});
}
} catch (equipmentError) {
console.error('Ошибка создания карточки имущества:', equipmentError);
}
}
fetchRequests();
fetchInventory();
setShowMoveToInventoryModal(false);
setSelectedRequest(null);
alert('Товар успешно добавлен на склад' + (isEquipment(selectedRequest) ? '. Карточка имущества создана с закреплением за офис-менеджером.' : ''));
}
}
} catch (error: any) {
console.error('Ошибка переноса на склад:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleOpenAddToEquipmentModal = (request: OfficeRequest) => {
setSelectedRequest(request);
setEquipmentFormData({
type: 'other',
brand: '',
model: '',
serialNumber: '',
assignedTo: request.requesterName || '', // Предзаполняем заказчика
purchaseDate: new Date().toISOString().split('T')[0],
warrantyUntil: '',
condition: 'good',
notes: `Создано из заявки #${request.id}. Заказчик: ${request.requesterName}`
});
setShowAddToEquipmentModal(true);
};
const handleAddToEquipment = async () => {
if (!selectedRequest) return;
// Проверяем обязательные поля
if (!equipmentFormData.serialNumber || !equipmentFormData.serialNumber.trim()) {
alert('Пожалуйста, укажите серийный номер');
return;
}
try {
const response = await authFetch('/api/office/equipment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: selectedRequest.itemName,
type: equipmentFormData.type,
brand: equipmentFormData.brand || null,
model: equipmentFormData.model || null,
serialNumber: equipmentFormData.serialNumber.trim(),
assignedTo: equipmentFormData.assignedTo || null,
purchaseDate: equipmentFormData.purchaseDate || null,
warrantyUntil: equipmentFormData.warrantyUntil || null,
condition: equipmentFormData.condition,
notes: equipmentFormData.notes || null
})
});
if (response.ok) {
const equipment = await response.json();
// Сохраняем ID оборудования в примечаниях заявки
await authFetch(`/api/office/supply-requests/${selectedRequest.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: `${selectedRequest.notes || ''}\n[Equipment ID: ${equipment.id}]`.trim()
})
});
fetchRequests();
setShowAddToEquipmentModal(false);
setSelectedRequest(null);
alert('Оборудование успешно добавлено в имущество');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка добавления: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка добавления в имущество:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const filteredRequests = requests.filter(req => {
// Исключаем архивные заявки из основного списка
if (req.status === 'archived' || req.status === 'collected') {
return false;
}
const itemName = (req.itemName || '').toLowerCase();
const requesterName = (req.requesterName || '').toLowerCase();
const searchLower = search.toLowerCase();
return itemName.includes(searchLower) || requesterName.includes(searchLower);
});
const filteredInventory = inventory.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);
const getStatusLabel = (status: string) => {
const labels = {
new: 'Ожидание',
approved: 'Одобрено',
ordered: 'Заказано',
received: 'На месте',
canceled: 'Отменено',
archived: 'В архиве',
collected: 'Забрал сотрудник'
};
return labels[status as keyof typeof labels] || status;
};
return (
<div className="space-y-4 animate-fade-in">
{/* Sub-tabs toggle */}
<div className="flex bg-slate-200/50 p-1 rounded-xl w-full">
<button
onClick={() => setView('requests')}
className={`flex-1 py-2 text-xs font-bold rounded-lg transition-all ${view === 'requests' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Заявки на ТМЦ
</button>
<button
onClick={() => setView('orders')}
className={`flex-1 py-2 text-xs font-bold rounded-lg transition-all ${view === 'orders' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Заказы
</button>
<button
onClick={() => setView('inventory')}
className={`flex-1 py-2 text-xs font-bold rounded-lg transition-all ${view === 'inventory' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Склад офиса
</button>
</div>
{/* Header with Create button */}
<div className="flex justify-between items-center">
<div className="relative flex-1 mr-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder={view === 'requests' ? "Поиск по заявкам..." : view === 'orders' ? "Поиск по заказам..." : "Поиск по складу..."}
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 focus:ring-2 focus:ring-primary-500 outline-none text-sm shadow-sm"
/>
</div>
{view === 'requests' && (
<div className="flex gap-2">
<button
onClick={() => setShowCreateRequestModal(true)}
className="bg-emerald-600 text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-emerald-700 transition-colors flex items-center gap-2 shadow-lg"
>
<Plus className="w-4 h-4" />
Оставить заявку
</button>
</div>
)}
{view === 'orders' && (
<div className="flex gap-2">
<button
onClick={() => {
// Переключаемся на заявки и открываем режим оформления заказа
setView('requests');
setShowCreateOrderModal(true);
}}
className="bg-primary-600 text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors flex items-center gap-2 shadow-lg"
>
<ClipboardList className="w-4 h-4" />
Оформить заказ
</button>
</div>
)}
</div>
{view === 'orders' ? (
<div className="space-y-3">
{loading ? (
<div className="text-center py-8 text-slate-500">Загрузка...</div>
) : orders && orders.length > 0 ? (
orders
.filter((order: any) => {
const searchLower = search.toLowerCase();
return (order.order_number || '').toLowerCase().includes(searchLower) ||
(order.title || '').toLowerCase().includes(searchLower) ||
(order.supplier_name || '').toLowerCase().includes(searchLower);
})
.map((order: any) => (
<OrderCard
key={order.id}
order={order}
onUpdate={fetchOrders}
/>
))
) : (
<div className="text-center py-8 text-slate-500">Нет заказов</div>
)}
</div>
) : view === 'requests' ? (
<div className="space-y-3">
{/* Фильтры по категориям для оформления заказа */}
{showCreateOrderModal && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
<p className="text-sm font-bold text-blue-800 mb-3">Выберите категорию для быстрого выбора:</p>
<div className="flex flex-wrap gap-2">
{['stationery', 'household', 'food', 'equipment', 'other'].map(cat => {
const labels: Record<string, string> = {
stationery: 'Канцтовары',
household: 'Хоз. нужды',
food: 'Продукты',
equipment: 'Оборудование',
other: 'Другое'
};
const count = requests.filter(r => (r.status === 'new' || r.status === 'approved') && r.category === cat).length;
return (
<button
key={cat}
onClick={() => handleSelectCategoryRequests(cat)}
className={`px-4 py-2 rounded-lg text-xs font-bold transition-colors ${
selectedCategory === cat
? 'bg-blue-600 text-white'
: 'bg-white text-blue-700 hover:bg-blue-100'
}`}
>
{labels[cat]} ({count})
</button>
);
})}
</div>
</div>
)}
{loading ? (
<div className="text-center py-8 text-slate-500">Загрузка...</div>
) : filteredRequests && filteredRequests.length > 0 ? (
filteredRequests.map(req => {
if (!req || !req.id) return null;
const isSelected = selectedRequestsForOrder.has(req.id);
const canSelect = showCreateOrderModal && (req.status === 'new' || req.status === 'approved');
return (
<div key={req.id} className={`bg-white p-4 rounded-xl border shadow-sm relative overflow-hidden group ${
isSelected ? 'border-primary-500 bg-primary-50' : 'border-slate-200'
}`}>
{canSelect && (
<div className="absolute top-2 right-2">
<button
onClick={() => handleToggleRequestSelection(req.id)}
className={`p-1.5 rounded-lg transition-colors ${
isSelected
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-400 hover:bg-slate-200'
}`}
>
{isSelected ? <CheckSquare className="w-4 h-4" /> : <CheckSquare className="w-4 h-4" />}
</button>
</div>
)}
<div className={`absolute left-0 top-0 bottom-0 w-1.5 ${
req.status === 'new' ? 'bg-blue-500' :
req.status === 'received' ? 'bg-emerald-500' :
req.status === 'ordered' ? 'bg-amber-500' :
req.status === 'collected' ? 'bg-green-500' :
req.status === 'archived' ? 'bg-slate-400' :
'bg-slate-400'
}`}/>
<div className="pl-3 flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-[10px] font-black px-2 py-0.5 rounded uppercase ${
req.priority === 'urgent' ? 'bg-red-100 text-red-600' :
req.priority === 'high' ? 'bg-orange-100 text-orange-600' :
'bg-slate-100 text-slate-500'
}`}>
{req.priority === 'urgent' ? 'Срочно' : req.priority === 'high' ? 'Высокий' : req.priority === 'medium' ? 'Средний' : 'Низкий'}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase">
{req.createdAt ? new Date(req.createdAt).toLocaleDateString('ru-RU') : req.date}
</span>
</div>
<h4 className="font-bold text-slate-800 text-sm group-hover:text-primary-600 transition-colors">{req.itemName}</h4>
<p className="text-[10px] text-slate-500 mt-0.5 font-medium italic">Заказчик: {req.requesterName}</p>
{req.quantity && req.unit && (
<p className="text-[10px] text-slate-500 mt-0.5">Количество: {req.quantity} {req.unit}</p>
)}
</div>
<div className="text-right ml-4">
<p className="font-black text-slate-900 text-sm">{req.amount.toLocaleString()} </p>
<span className="text-[9px] font-black text-slate-400 uppercase tracking-tighter block mb-2">
{getStatusLabel(req.status)}
</span>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleCheckRequest(req)}
className="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-xs font-bold hover:bg-blue-100 transition-colors flex items-center gap-1"
title="Проверить наличие на складе"
>
<CheckCircle2 className="w-3 h-3" />
Проверить
</button>
<button
onClick={() => handleOpenEditModal(req)}
className="px-3 py-1.5 bg-amber-50 text-amber-600 rounded-lg text-xs font-bold hover:bg-amber-100 transition-colors flex items-center gap-1"
title="Редактировать заявку"
>
<Edit className="w-3 h-3" />
Редактировать
</button>
{req.status === 'new' && (
<button
onClick={() => handleApproveRequest(req.id)}
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold hover:bg-emerald-100 transition-colors"
title="Одобрить заявку"
>
Одобрить
</button>
)}
{req.status === 'ordered' && (
<button
onClick={() => handleMarkAsReceived(req.id)}
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold hover:bg-emerald-100 transition-colors flex items-center gap-1"
title="Отметить как получено"
>
<CheckCircle2 className="w-3 h-3" />
Получено
</button>
)}
{req.status === 'received' && (
<>
<button
onClick={() => handleMarkAsCollected(req.id)}
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold hover:bg-emerald-100 transition-colors flex items-center gap-1"
title="Отметить, что сотрудник забрал товар"
>
<CheckCircle2 className="w-3 h-3" />
Забрал сотрудник
</button>
<button
onClick={() => handleOpenMoveToInventoryModal(req)}
className="px-3 py-1.5 bg-violet-50 text-violet-600 rounded-lg text-xs font-bold hover:bg-violet-100 transition-colors flex items-center gap-1"
title="Перенести на склад"
>
<Warehouse className="w-3 h-3" />
На склад
</button>
<button
onClick={() => {
const requester = req.requesterName;
alert(`Уведомление отправлено: ${requester}, ваш заказ "${req.itemName}" получен!`);
}}
className="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-xs font-bold hover:bg-blue-100 transition-colors flex items-center gap-1"
title="Уведомить сотрудника"
>
<Bell className="w-3 h-3" />
Уведомить
</button>
<button
onClick={() => handleArchiveRequest(req.id)}
className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-xs font-bold hover:bg-slate-100 transition-colors flex items-center gap-1"
title="Переместить в архив"
>
<Package className="w-3 h-3" />
В архив
</button>
</>
)}
{(req.status === 'collected' || req.status === 'archived') && (
<button
onClick={() => handleArchiveRequest(req.id)}
className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-xs font-bold hover:bg-slate-100 transition-colors flex items-center gap-1"
title="Переместить в архив"
>
<Package className="w-3 h-3" />
В архив
</button>
)}
{(req.status === 'ordered' || req.status === 'approved' || req.status === 'received') && (
<>
{(req.category === 'equipment' ||
req.itemName?.toLowerCase().includes('компьютер') ||
req.itemName?.toLowerCase().includes('ноутбук') ||
req.itemName?.toLowerCase().includes('микроволновка') ||
req.itemName?.toLowerCase().includes('принтер') ||
req.itemName?.toLowerCase().includes('монитор') ||
req.itemName?.toLowerCase().includes('кондиционер') ||
req.itemName?.toLowerCase().includes('телефон') ||
req.itemName?.toLowerCase().includes('сканер') ||
req.itemName?.toLowerCase().includes('кондер')) && (
<button
onClick={() => handleOpenAddToEquipmentModal(req)}
className="px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-lg text-xs font-bold hover:bg-indigo-100 transition-colors flex items-center gap-1"
title="Добавить в имущество"
>
<Monitor className="w-3 h-3" />
В имущество
</button>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}).filter(Boolean)
) : (
<div className="text-center py-8 text-slate-500">Нет заявок</div>
)}
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 text-[10px] uppercase font-black border-b border-slate-200 tracking-widest">
<tr>
<th className="p-4">Наименование</th>
<th className="p-4 text-right">Наличие</th>
<th className="p-4 text-right">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredInventory && filteredInventory.length > 0 ? filteredInventory.map(item => {
if (!item || !item.id) return null;
const isLow = (item.quantity || 0) <= (item.minThreshold || 0);
// Находим заявки, которые можно выдать со склада
const availableRequests = requests.filter(req =>
(req.status === 'approved' || req.status === 'ordered') &&
req.itemName?.toLowerCase() === item.name?.toLowerCase() &&
(req.issuedQuantity || 0) < (req.quantity || 0)
);
return (
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4">
<p className="font-bold text-slate-800 text-xs">{item.name}</p>
<p className="text-[10px] text-slate-400 font-medium">
{item.lastRestock ? `Обновлено: ${item.lastRestock}` : 'Не обновлялось'}
</p>
</td>
<td className="p-4 text-right">
<div className="flex flex-col items-end">
<span className={`font-black text-sm ${isLow ? 'text-red-600' : 'text-emerald-600'}`}>
{item.quantity} {item.unit}
</span>
{isLow && <span className="text-[8px] font-black text-red-500 uppercase tracking-tighter">Критично</span>}
</div>
</td>
<td className="p-4">
<div className="flex gap-2 justify-end">
{availableRequests.length > 0 && (
<button
onClick={() => {
setSelectedInventoryItem(item);
setIssueFormData({
quantity: 0,
requestId: availableRequests[0].id.toString(),
notes: ''
});
setShowIssueFromInventoryModal(true);
}}
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold hover:bg-emerald-100 transition-colors flex items-center gap-1"
title="Выдать по заявке"
>
<Package className="w-3 h-3" />
Выдать
</button>
)}
<button
onClick={async () => {
setWriteOffSource('inventory');
setSelectedInventoryItem(item);
setSelectedEquipmentForWriteOff(null);
setWriteOffFormData({
quantity: 0,
reason: ''
});
setShowWriteOffModal(true);
}}
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 transition-colors flex items-center gap-1"
title="Списать товар"
>
<Trash2 className="w-3 h-3" />
Списать
</button>
</div>
</td>
</tr>
);
}).filter(Boolean) : (
<tr>
<td colSpan={3} className="p-4 text-center text-slate-500">Нет товаров на складе</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Create Order Modal - Select Requests */}
{showCreateOrderModal && (
<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-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">Оформить заказ</h3>
<p className="text-xs text-slate-500 mt-1">Выберите заявки для объединения в заказ</p>
</div>
<button
onClick={() => {
setShowCreateOrderModal(false);
setSelectedRequestsForOrder(new Set());
setSelectedCategory(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick category selection */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
<p className="text-sm font-bold text-blue-800 mb-3">Быстрый выбор по категориям:</p>
<div className="flex flex-wrap gap-2">
{['stationery', 'household', 'food', 'equipment', 'other'].map(cat => {
const labels: Record<string, string> = {
stationery: 'Канцтовары',
household: 'Хоз. нужды',
food: 'Продукты',
equipment: 'Оборудование',
other: 'Другое'
};
const availableRequests = requests.filter(r => (r.status === 'new' || r.status === 'approved') && r.category === cat);
const selectedCount = availableRequests.filter(r => selectedRequestsForOrder.has(r.id)).length;
return (
<button
key={cat}
onClick={() => handleSelectCategoryRequests(cat)}
className={`px-4 py-2 rounded-lg text-xs font-bold transition-colors ${
selectedCategory === cat
? 'bg-blue-600 text-white'
: 'bg-white text-blue-700 hover:bg-blue-100'
}`}
>
{labels[cat]} ({selectedCount}/{availableRequests.length})
</button>
);
})}
</div>
</div>
{/* Selected requests list */}
<div className="flex-1 min-h-0 overflow-y-auto mb-4 border border-slate-200 rounded-xl">
<div className="divide-y divide-slate-100">
{requests
.filter(req => req.status === 'new' || req.status === 'approved')
.map(req => {
const isSelected = selectedRequestsForOrder.has(req.id);
const categoryLabels: Record<string, string> = {
stationery: 'Канцтовары',
household: 'Хоз. нужды',
food: 'Продукты',
equipment: 'Оборудование',
other: 'Другое'
};
return (
<div
key={req.id}
onClick={() => handleToggleRequestSelection(req.id)}
className={`p-4 cursor-pointer transition-colors ${
isSelected ? 'bg-primary-50 border-l-4 border-primary-500' : 'hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-1 p-1 rounded ${
isSelected ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-400'
}`}>
<CheckSquare className="w-4 h-4" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-slate-500">{categoryLabels[req.category] || req.category}</span>
<span className="text-xs text-slate-400"></span>
<span className="text-xs text-slate-400">{req.requesterName}</span>
</div>
<h4 className="font-bold text-slate-800 text-sm">{req.itemName}</h4>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>{req.quantity} {req.unit}</span>
<span className="font-bold text-slate-800">{req.amount.toLocaleString()} </span>
</div>
</div>
</div>
</div>
);
})}
{requests.filter(req => req.status === 'new' || req.status === 'approved').length === 0 && (
<div className="p-8 text-center text-slate-400">
<p>Нет доступных заявок для оформления заказа</p>
</div>
)}
</div>
</div>
{/* Summary and actions */}
<div className="pt-4 border-t border-slate-200">
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-sm text-slate-600">Выбрано заявок: <span className="font-bold text-slate-800">{selectedRequestsForOrder.size}</span></p>
<p className="text-lg font-black text-slate-900 mt-1">
Итого: {requests
.filter(req => selectedRequestsForOrder.has(req.id))
.reduce((sum, req) => sum + (req.amount || 0), 0)
.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowCreateOrderModal(false);
setSelectedRequestsForOrder(new Set());
setSelectedCategory(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>
<button
onClick={handleCreateOrder}
disabled={selectedRequestsForOrder.size === 0}
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center gap-2"
>
<ClipboardList className="w-4 h-4" />
Оформить заказ и создать счет
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Create Request Modal - Table */}
{showCreateRequestModal && (
<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-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">Оставить заявку на ТМЦ</h3>
<p className="text-xs text-slate-500 mt-1">Добавьте позиции и оставьте заявку (цены укажет офис-менеджер)</p>
</div>
<button
onClick={() => {
setShowCreateRequestModal(false);
setRequestItems([{
id: '1',
category: 'stationery',
itemName: '',
quantity: 1,
unit: 'шт.',
amount: 0,
priority: 'medium',
notes: ''
}]);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto mb-4">
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200 sticky top-0">
<tr>
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">Категория</th>
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">Наименование</th>
<th className="p-3 text-center text-xs font-bold text-slate-600 uppercase">Кол-во</th>
<th className="p-3 text-center text-xs font-bold text-slate-600 uppercase">Ед.</th>
<th className="p-3 text-center text-xs font-bold text-slate-600 uppercase">Приоритет</th>
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">Примечания</th>
<th className="p-3 text-center text-xs font-bold text-slate-600 uppercase w-12"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{requestItems.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="p-2">
<select
value={item.category}
onChange={(e) => handleUpdateRequestItem(item.id, 'category', e.target.value)}
className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="stationery">Канцтовары</option>
<option value="household">Хоз. нужды</option>
<option value="food">Продукты</option>
<option value="equipment">Оборудование</option>
<option value="other">Другое</option>
</select>
</td>
<td className="p-2">
<input
type="text"
value={item.itemName}
onChange={(e) => handleUpdateRequestItem(item.id, 'itemName', e.target.value)}
className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="Наименование товара"
/>
</td>
<td className="p-2">
<input
type="number"
value={item.quantity}
onChange={(e) => {
const qty = parseInt(e.target.value) || 1;
handleUpdateRequestItem(item.id, 'quantity', qty);
}}
className="w-20 px-2 py-1.5 border border-slate-300 rounded text-xs text-center focus:ring-2 focus:ring-primary-500 outline-none"
min="1"
/>
</td>
<td className="p-2">
<input
type="text"
value={item.unit}
onChange={(e) => handleUpdateRequestItem(item.id, 'unit', e.target.value)}
className="w-16 px-2 py-1.5 border border-slate-300 rounded text-xs text-center focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="шт."
/>
</td>
<td className="p-2">
<select
value={item.priority}
onChange={(e) => handleUpdateRequestItem(item.id, 'priority', e.target.value)}
className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
<option value="urgent">Срочно</option>
</select>
</td>
<td className="p-2">
<input
type="text"
value={item.notes}
onChange={(e) => handleUpdateRequestItem(item.id, 'notes', e.target.value)}
className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="Доп. информация"
/>
</td>
<td className="p-2">
{requestItems.length > 1 && (
<button
onClick={() => handleRemoveRequestRow(item.id)}
className="p-1.5 text-red-500 hover:bg-red-50 rounded transition-colors"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-slate-50 border-t-2 border-slate-300">
<tr>
<td colSpan={6} className="p-3 text-center text-slate-500 text-xs">
Всего позиций: {requestItems.length}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
<button
onClick={handleAddRequestRow}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-bold hover:bg-slate-200 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить строку
</button>
<div className="flex gap-3">
<button
onClick={() => {
setShowCreateRequestModal(false);
setRequestItems([{
id: '1',
category: 'stationery',
itemName: '',
quantity: 1,
unit: 'шт.',
amount: 0,
priority: 'medium',
notes: ''
}]);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
onClick={handleCreateRequest}
disabled={requestItems.every(item => !item.itemName || !item.itemName.trim())}
className="px-6 py-2 bg-emerald-600 text-white rounded-lg font-bold text-sm hover:bg-emerald-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Оставить заявку
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit Request Modal */}
{showEditModal && selectedRequest && (
<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={() => {
setShowEditModal(false);
setSelectedRequest(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={selectedRequest.itemName}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Сумма ()</label>
<input
type="number"
value={editFormData.amount}
onChange={(e) => setEditFormData({ ...editFormData, amount: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
min="0"
step="0.01"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Статус</label>
<select
value={editFormData.status}
onChange={(e) => setEditFormData({ ...editFormData, status: e.target.value as any })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="new">Ожидание</option>
<option value="approved">Одобрено</option>
<option value="ordered">Заказано</option>
<option value="received">На месте</option>
<option value="canceled">Отменено</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Приоритет</label>
<select
value={editFormData.priority}
onChange={(e) => setEditFormData({ ...editFormData, priority: e.target.value as any })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
<option value="urgent">Срочно</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
<textarea
value={editFormData.notes}
onChange={(e) => setEditFormData({ ...editFormData, 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={handleSaveEdit}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
Сохранить
</button>
<button
onClick={() => {
setShowEditModal(false);
setSelectedRequest(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>
)}
{/* Move to Inventory Modal */}
{showMoveToInventoryModal && selectedRequest && (
<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={() => {
setShowMoveToInventoryModal(false);
setSelectedRequest(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={selectedRequest.itemName}
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">{selectedRequest.quantity || 0} {selectedRequest.unit || 'шт.'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600">Отдано сотруднику:</span>
<span className="text-sm font-bold text-slate-800">{selectedRequest.issuedQuantity || 0} {selectedRequest.unit || 'шт.'}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-slate-200">
<span className="text-sm font-medium text-slate-700">Доступно для склада:</span>
<span className="text-sm font-bold text-primary-600">{(selectedRequest.quantity || 0) - (selectedRequest.issuedQuantity || 0)} {selectedRequest.unit || 'шт.'}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Количество на склад</label>
<input
type="number"
value={inventoryFormData.quantity}
onChange={(e) => {
const maxQty = (selectedRequest.quantity || 0) - (selectedRequest.issuedQuantity || 0);
const value = Math.min(Math.max(0, parseInt(e.target.value) || 0), maxQty);
setInventoryFormData({ ...inventoryFormData, quantity: value });
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
min="0"
max={(selectedRequest.quantity || 0) - (selectedRequest.issuedQuantity || 0)}
/>
<p className="text-xs text-slate-400 mt-1">Заказано: {selectedRequest.quantity} {selectedRequest.unit}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Место хранения</label>
<input
type="text"
value={inventoryFormData.location}
onChange={(e) => setInventoryFormData({ ...inventoryFormData, location: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Например: Склад №1, Полка 3"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
<textarea
value={inventoryFormData.notes}
onChange={(e) => setInventoryFormData({ ...inventoryFormData, notes: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
placeholder="Дополнительная информация"
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleMoveToInventory}
className="flex-1 bg-violet-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-violet-700 transition-colors flex items-center justify-center gap-2"
>
<Warehouse className="w-4 h-4" />
Перенести на склад
</button>
<button
onClick={() => {
setShowMoveToInventoryModal(false);
setSelectedRequest(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>
)}
{/* Issue from Inventory Modal */}
{showIssueFromInventoryModal && selectedInventoryItem && (
<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={() => {
setShowIssueFromInventoryModal(false);
setSelectedInventoryItem(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={selectedInventoryItem.name}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Доступно на складе</label>
<input
type="text"
value={`${selectedInventoryItem.quantity} ${selectedInventoryItem.unit}`}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Заявка</label>
<select
value={issueFormData.requestId}
onChange={(e) => {
const requestId = e.target.value;
const request = requests.find(r => r.id.toString() === requestId);
const maxQty = Math.min(
selectedInventoryItem.quantity,
(request?.quantity || 0) - (request?.issuedQuantity || 0)
);
setIssueFormData({
...issueFormData,
requestId,
quantity: Math.min(issueFormData.quantity || 0, maxQty)
});
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Выберите заявку</option>
{requests
.filter(req =>
(req.status === 'approved' || req.status === 'ordered') &&
req.itemName?.toLowerCase() === selectedInventoryItem.name?.toLowerCase() &&
(req.issuedQuantity || 0) < (req.quantity || 0)
)
.map(req => (
<option key={req.id} value={req.id.toString()}>
{req.requesterName} - {req.quantity} {req.unit} (выдано: {req.issuedQuantity || 0})
</option>
))}
</select>
</div>
{issueFormData.requestId && (() => {
const request = requests.find(r => r.id.toString() === issueFormData.requestId);
const maxQty = Math.min(
selectedInventoryItem.quantity,
(request?.quantity || 0) - (request?.issuedQuantity || 0)
);
return (
<>
<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">{request?.quantity || 0} {request?.unit || 'шт.'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600">Уже выдано:</span>
<span className="text-sm font-bold text-slate-800">{request?.issuedQuantity || 0} {request?.unit || 'шт.'}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-slate-200">
<span className="text-sm font-medium text-slate-700">Можно выдать:</span>
<span className="text-sm font-bold text-primary-600">{maxQty} {request?.unit || 'шт.'}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Количество к выдаче</label>
<input
type="number"
value={issueFormData.quantity}
onChange={(e) => {
const value = Math.min(Math.max(0, parseInt(e.target.value) || 0), maxQty);
setIssueFormData({ ...issueFormData, quantity: value });
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
min="0"
max={maxQty}
/>
</div>
</>
);
})()}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
<textarea
value={issueFormData.notes}
onChange={(e) => setIssueFormData({ ...issueFormData, notes: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
placeholder="Дополнительная информация"
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={async () => {
if (!issueFormData.requestId || !issueFormData.quantity || issueFormData.quantity <= 0) {
alert('Заполните все поля');
return;
}
try {
const request = requests.find(r => r.id.toString() === issueFormData.requestId);
if (!request) {
alert('Заявка не найдена');
return;
}
// Обновляем количество выданного в заявке
const newIssuedQty = (request.issuedQuantity || 0) + issueFormData.quantity;
await authFetch(`/api/office/supply-requests/${request.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
issued_quantity: newIssuedQty
})
});
// Уменьшаем количество на складе
await authFetch(`/api/office/inventory/${selectedInventoryItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quantity: (selectedInventoryItem.quantity || 0) - issueFormData.quantity
})
});
fetchRequests();
fetchInventory();
setShowIssueFromInventoryModal(false);
setSelectedInventoryItem(null);
alert(`Выдано ${issueFormData.quantity} ${selectedInventoryItem.unit} по заявке ${request.requesterName}`);
} catch (error: any) {
console.error('Ошибка выдачи:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
}}
disabled={!issueFormData.requestId || !issueFormData.quantity || issueFormData.quantity <= 0}
className="flex-1 bg-emerald-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-emerald-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Package className="w-4 h-4" />
Выдать
</button>
<button
onClick={() => {
setShowIssueFromInventoryModal(false);
setSelectedInventoryItem(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>
)}
{/* Write Off Modal - Unified for Inventory and Equipment */}
{showWriteOffModal && (selectedInventoryItem || selectedEquipmentForWriteOff) && (
<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);
setSelectedInventoryItem(null);
setSelectedEquipmentForWriteOff(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={writeOffSource === 'inventory' ? selectedInventoryItem?.name : selectedEquipmentForWriteOff?.name}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
{/* Информация о наличии */}
{writeOffSource === 'inventory' && selectedInventoryItem && (
<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">{selectedInventoryItem.quantity} {selectedInventoryItem.unit}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600">В имуществе:</span>
<span className="text-sm font-bold text-slate-800">
{equipment.filter((eq: any) =>
eq.name?.toLowerCase() === selectedInventoryItem.name?.toLowerCase()
).length} шт.
</span>
</div>
</div>
)}
{writeOffSource === 'equipment' && selectedEquipmentForWriteOff && (
<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>
<span className="text-sm font-bold text-slate-800">
{(() => {
const inventoryItem = inventory.find(item =>
item.name?.toLowerCase() === selectedEquipmentForWriteOff.name?.toLowerCase()
);
return inventoryItem ? `${inventoryItem.quantity} ${inventoryItem.unit}` : '0 шт.';
})()}
</span>
</div>
</div>
)}
{writeOffSource === 'inventory' && selectedInventoryItem && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Количество к списанию</label>
<input
type="number"
value={writeOffFormData.quantity}
onChange={(e) => {
const maxQty = selectedInventoryItem.quantity || 0;
const value = Math.min(Math.max(0, parseInt(e.target.value) || 0), maxQty);
setWriteOffFormData({ ...writeOffFormData, quantity: value });
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
min="0"
max={selectedInventoryItem.quantity || 0}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Причина списания *</label>
<textarea
value={writeOffFormData.reason}
onChange={(e) => setWriteOffFormData({ ...writeOffFormData, 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>При списании оборудование будет удалено {writeOffSource === 'inventory' ? 'со склада и из имущества' : 'из имущества и со склада'} одновременно.</p>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={async () => {
if (writeOffSource === 'inventory' && (!writeOffFormData.quantity || writeOffFormData.quantity <= 0)) {
alert('Укажите количество для списания');
return;
}
if (!writeOffFormData.reason || !writeOffFormData.reason.trim()) {
alert('Укажите причину списания');
return;
}
try {
const itemName = writeOffSource === 'inventory' ? selectedInventoryItem?.name : selectedEquipmentForWriteOff?.name;
const writeOffNote = `Списано. Причина: ${writeOffFormData.reason}. Дата: ${new Date().toLocaleDateString('ru-RU')}`;
// Если списываем со склада
if (writeOffSource === 'inventory' && selectedInventoryItem) {
// Уменьшаем количество на складе
const newQuantity = (selectedInventoryItem.quantity || 0) - writeOffFormData.quantity;
const existingNotes = selectedInventoryItem.notes || '';
const updatedNotes = existingNotes ? `${existingNotes}\n${writeOffNote}` : writeOffNote;
await authFetch(`/api/office/inventory/${selectedInventoryItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quantity: newQuantity,
notes: updatedNotes
})
});
// Ищем и списываем из имущества (помечаем как списанное)
const equipmentList = await fetchEquipment();
const matchingEquipment = equipmentList.filter((eq: any) =>
eq.name?.toLowerCase() === itemName?.toLowerCase()
);
const today = new Date().toISOString().slice(0, 10);
for (const eq of matchingEquipment) {
await authFetch(`/api/office/equipment/${eq.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 = eq.notes || '';
const updatedEqNotes = existingEqNotes ? `${existingEqNotes}\n${writeOffNote}` : writeOffNote;
await authFetch(`/api/office/equipment/${eq.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
condition: 'poor',
notes: updatedEqNotes
})
});
}
}
// Если списываем из имущества
if (writeOffSource === 'equipment' && selectedEquipmentForWriteOff) {
const today = new Date().toISOString().slice(0, 10);
await authFetch(`/api/office/equipment/${selectedEquipmentForWriteOff.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 = selectedEquipmentForWriteOff.notes || '';
const updatedEqNotes = existingEqNotes ? `${existingEqNotes}\n${writeOffNote}` : writeOffNote;
await authFetch(`/api/office/equipment/${selectedEquipmentForWriteOff.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
condition: 'poor',
notes: updatedEqNotes
})
});
// Ищем и списываем со склада
const matchingInventory = inventory.find(item =>
item.name?.toLowerCase() === itemName?.toLowerCase()
);
if (matchingInventory) {
const newQuantity = Math.max(0, (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
})
});
}
}
await fetchInventory();
await fetchEquipmentForWriteOff();
setShowWriteOffModal(false);
setSelectedInventoryItem(null);
setSelectedEquipmentForWriteOff(null);
alert(`Оборудование списано ${writeOffSource === 'inventory' ? 'со склада и из имущества' : 'из имущества и со склада'}`);
} catch (error: any) {
console.error('Ошибка списания:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
}}
disabled={
(writeOffSource === 'inventory' && (!writeOffFormData.quantity || writeOffFormData.quantity <= 0)) ||
!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);
setSelectedInventoryItem(null);
setSelectedEquipmentForWriteOff(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>
)}
{/* Add to Equipment Modal */}
{showAddToEquipmentModal && selectedRequest && (
<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={() => {
setShowAddToEquipmentModal(false);
setSelectedRequest(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={selectedRequest.itemName}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Тип оборудования</label>
<select
value={equipmentFormData.type}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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="printer">Принтер</option>
<option value="air_conditioner">Кондиционер</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={equipmentFormData.brand}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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={equipmentFormData.model}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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">
Серийный номер <span className="text-red-500">*</span>
</label>
<input
type="text"
value={equipmentFormData.serialNumber}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, serialNumber: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Обязательно для заполнения"
required
/>
</div>
<div className="relative">
<label className="block text-sm font-medium text-slate-700 mb-1">Закреплено за</label>
<input
type="text"
value={equipmentAssignedDropdownOpen ? equipmentAssignedFilter : equipmentFormData.assignedTo}
onChange={(e) => {
setEquipmentAssignedFilter(e.target.value);
setEquipmentAssignedDropdownOpen(true);
}}
onFocus={() => {
setEquipmentAssignedFilter(equipmentFormData.assignedTo);
setEquipmentAssignedDropdownOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setEquipmentAssignedDropdownOpen(false);
setEquipmentAssignedFilter(equipmentFormData.assignedTo);
}, 200);
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Начните вводить имя..."
/>
{equipmentAssignedDropdownOpen && (
<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={() => {
setEquipmentFormData({ ...equipmentFormData, assignedTo: '' });
setEquipmentAssignedFilter('');
setEquipmentAssignedDropdownOpen(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(equipmentAssignedFilter.toLowerCase()) ||
(emp.position && emp.position.toLowerCase().includes(equipmentAssignedFilter.toLowerCase()))
)
.map((emp) => (
<button
key={emp.id}
type="button"
onClick={() => {
setEquipmentFormData({ ...equipmentFormData, assignedTo: emp.name });
setEquipmentAssignedFilter(emp.name);
setEquipmentAssignedDropdownOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
{emp.name}{emp.position ? `${emp.position}` : ''}
</button>
))}
{equipmentFormData.assignedTo && !employees.some((e) => e.name === equipmentFormData.assignedTo) &&
(equipmentFormData.assignedTo.toLowerCase().includes(equipmentAssignedFilter.toLowerCase()) || !equipmentAssignedFilter) && (
<button
type="button"
onClick={() => {
setEquipmentAssignedFilter(equipmentFormData.assignedTo);
setEquipmentAssignedDropdownOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-100 border-t border-slate-200"
>
{equipmentFormData.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={equipmentFormData.purchaseDate}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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={equipmentFormData.warrantyUntil}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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>
<select
value={equipmentFormData.condition}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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={equipmentFormData.notes}
onChange={(e) => setEquipmentFormData({ ...equipmentFormData, 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={handleAddToEquipment}
disabled={!equipmentFormData.serialNumber || !equipmentFormData.serialNumber.trim()}
className="flex-1 bg-indigo-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-indigo-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Monitor className="w-4 h-4" />
Добавить в имущество
</button>
<button
onClick={() => {
setShowAddToEquipmentModal(false);
setSelectedRequest(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>
);
};
const formatCurrency = (amount: number): string => {
if (isNaN(amount) || !isFinite(amount)) {
return '0 ₽';
}
const rounded = Math.round(amount);
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(rounded) + ' ₽';
};
// Компонент карточки заказа
const OrderCard = ({ order, onUpdate }: { order: any; onUpdate: () => void }) => {
const [showOrderModal, setShowOrderModal] = useState(false);
const [quotes, setQuotes] = useState<any[]>([]);
const [orderItems, setOrderItems] = useState<any[]>([]);
const [invoiceStatus, setInvoiceStatus] = useState<string | null>(null);
const [invoiceMeta, setInvoiceMeta] = useState<{ scheduledDate?: string; paymentDate?: string } | null>(null);
const getStatusColor = (status: string) => {
switch (status) {
case 'draft': return 'bg-slate-100 text-slate-700';
case 'waiting_quote': return 'bg-blue-100 text-blue-700';
case 'quotes_received': return 'bg-amber-100 text-amber-700';
case 'approved': return 'bg-emerald-100 text-emerald-700';
case 'ordered': return 'bg-violet-100 text-violet-700';
case 'received': return 'bg-green-100 text-green-700';
case 'canceled': return 'bg-red-100 text-red-700';
default: return 'bg-slate-100 text-slate-700';
}
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
draft: 'Черновик',
waiting_quote: 'Ожидание счета',
quotes_received: 'Счета получены',
approved: 'Одобрено',
ordered: 'Заказано',
received: 'Получено',
canceled: 'Отменено'
};
return labels[status] || status;
};
useEffect(() => {
if (showOrderModal) {
fetchOrderDetails();
}
}, [showOrderModal, order.id]);
useEffect(() => {
const invoiceId = order.invoice_id || order.invoiceId;
if (!invoiceId) {
setInvoiceStatus(null);
setInvoiceMeta(null);
return;
}
(async () => {
try {
const resp = await authFetch(`/api/finance/payment-invoices/${invoiceId}`);
if (!resp.ok) return;
const inv = await resp.json();
setInvoiceStatus(inv.status || null);
setInvoiceMeta({
scheduledDate: inv.scheduledDate,
paymentDate: inv.paymentDate
});
} catch (e) {
console.error('Ошибка загрузки статуса счета:', e);
}
})();
}, [order.invoice_id, order.invoiceId]);
const fetchOrderDetails = async () => {
try {
const itemsResponse = await authFetch(`/api/office/orders/${order.id}/items`);
if (itemsResponse.ok) {
const itemsData = await itemsResponse.json();
setOrderItems(itemsData);
}
const quotesResponse = await authFetch(`/api/office/orders/${order.id}/quotes`);
if (quotesResponse.ok) {
const quotesData = await quotesResponse.json();
setQuotes(quotesData);
}
} catch (error) {
console.error('Ошибка загрузки деталей заказа:', error);
}
};
const handleCreateInvoice = async () => {
try {
// Подгружаем позиции заказа (чтобы перенести их в счет)
let items = orderItems;
if (!items || items.length === 0) {
const itemsResponse = await authFetch(`/api/office/orders/${order.id}/items`);
if (itemsResponse.ok) {
items = await itemsResponse.json();
setOrderItems(items);
}
}
const materialItems = (items || []).map((it: any) => {
const name = it.request?.itemName || it.item_name || it.name || 'Позиция';
const quantity = Number(it.quantity || 0) || 0;
const unit = it.request?.unit || it.unit || 'шт.';
const pricePerUnit = Number(it.unitPrice ?? it.unit_price ?? 0) || 0;
const amount = Number(it.totalPrice ?? it.total_price ?? (quantity * pricePerUnit)) || 0;
return { name, quantity, unit, pricePerUnit, amount };
});
// Проверяем, чтобы по всем позициям была указана цена
const itemsWithoutPrice = materialItems.filter(mi => !mi.pricePerUnit || mi.pricePerUnit <= 0);
if (itemsWithoutPrice.length > 0) {
alert(
'Перед постановкой счета на оплату нужно указать цену по всем позициям заказа.\n' +
'Откройте заказ и в блоке «Позиции заказа» проставьте цены, затем сохраните.'
);
return;
}
const response = await authFetch('/api/finance/payment-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
createdBy: CURRENT_USER_MOCK?.name || 'Система',
purposeType: 'office',
purposeDescription: `Заказ ${order.order_number}: ${order.title}`,
paymentFormat: 'postpayment',
contractorName: order.supplier_name || 'Поставщик',
itemType: 'materials',
materialItems,
totalAmount: materialItems.reduce((sum: number, mi: any) => sum + (Number(mi.amount) || 0), 0) || (order.total_amount || 0),
notes: `Источник: заказ ${order.order_number}\n${order.notes || ''}`.trim(),
fileUrls: []
})
});
if (response.ok) {
const invoice = await response.json();
await authFetch(`/api/office/orders/${order.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invoiceId: invoice.id,
invoiceUrl: `/api/finance/payment-invoices/${invoice.id}`
})
});
onUpdate();
setInvoiceStatus(invoice.status || 'draft');
// Открываем счет в модуле Финансы для загрузки файла и дальнейших действий
try {
const event = new CustomEvent('mkd-open-finance-invoice', {
detail: { invoiceId: invoice.id }
});
window.dispatchEvent(event);
} catch (e) {
console.error('Не удалось отправить событие открытия счета в Финансах:', e);
}
alert('Счет успешно создан. Открылся модуль \"Финансы\" для работы со счетом (загрузка файла, отметка выполнения и т.д.).');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания счета: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания счета:', error);
alert(`Ошибка создания счета: ${error.message || 'Неизвестная ошибка'}`);
}
};
return (
<>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-black text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
{order.order_number}
</span>
<span className={`text-[9px] font-bold px-2 py-0.5 rounded ${getStatusColor(order.status)}`}>
{getStatusLabel(order.status)}
</span>
</div>
<h4 className="font-bold text-slate-800 mb-1">{order.title}</h4>
<div className="flex items-center gap-4 text-xs text-slate-500 mb-2">
{order.supplier_name && <span>Поставщик: {order.supplier_name}</span>}
{order.total_amount > 0 && (
<span className="font-bold text-primary-600">
{formatCurrency(order.total_amount)}
</span>
)}
{order.expected_date && (
<span>Ожидается: {new Date(order.expected_date).toLocaleDateString('ru-RU')}</span>
)}
</div>
{order.description && (
<p className="text-xs text-slate-600 mb-2">{order.description}</p>
)}
{order.invoice_id && (
<div className="flex items-center gap-2 text-xs">
<Receipt className="w-3 h-3 text-primary-600" />
<span className="text-primary-600">
{invoiceStatus
? `Счет: ${invoiceStatus === 'paid' ? 'оплачен' :
invoiceStatus === 'scheduled' ? 'в графике' :
invoiceStatus === 'approved' ? 'одобрен' :
invoiceStatus === 'pending_manager_approval' ? 'на согласовании' :
invoiceStatus === 'pending_finance_manager_approval' ? 'на согласовании фин.' :
invoiceStatus === 'rejected' ? 'отклонен' :
invoiceStatus === 'cancelled' ? 'отменен' :
invoiceStatus}`
: 'Счет создан'}
</span>
{invoiceMeta?.paymentDate && (
<span className="text-slate-400"> {new Date(invoiceMeta.paymentDate).toLocaleDateString('ru-RU')}</span>
)}
</div>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => setShowOrderModal(true)}
className="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
title="Открыть заказ"
>
<FileText className="w-4 h-4" />
</button>
</div>
</div>
</div>
{showOrderModal && (
<OrderDetailsModal
order={order}
orderItems={orderItems}
quotes={quotes}
onClose={() => {
setShowOrderModal(false);
onUpdate();
}}
onCreateInvoice={handleCreateInvoice}
onUpdate={onUpdate}
/>
)}
</>
);
};
// Модальное окно деталей заказа
const OrderDetailsModal = ({
order,
orderItems,
quotes,
onClose,
onCreateInvoice,
onUpdate
}: {
order: any;
orderItems: any[];
quotes: any[];
onClose: () => void;
onCreateInvoice: () => void;
onUpdate: () => void;
}) => {
const [showAddQuoteModal, setShowAddQuoteModal] = useState(false);
const [quoteFormData, setQuoteFormData] = useState({
supplierName: '',
supplierContact: '',
totalAmount: '',
notes: ''
});
const [editableItems, setEditableItems] = useState<any[]>(() => orderItems || []);
useEffect(() => {
setEditableItems(orderItems || []);
}, [orderItems, order.id]);
const handleChangeItemPrice = (itemId: number, newPrice: number) => {
setEditableItems(prev =>
prev.map(it => it.id === itemId ? { ...it, unitPrice: newPrice, totalPrice: (it.quantity || 0) * newPrice } : it)
);
};
const handleSavePrices = async () => {
try {
for (const item of editableItems) {
// Обновляем позицию заказа
await authFetch(`/api/office/orders/${order.id}/items/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.totalPrice
})
});
// Синхронизируем цену в исходной заявке
const requestId = item.request_id || item.requestId || item.request?.id;
if (requestId) {
await authFetch(`/api/office/supply-requests/${requestId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: item.totalPrice || 0
})
});
}
}
onUpdate();
alert('Цены по позициям заказа сохранены и синхронизированы с заявками');
} catch (error: any) {
console.error('Ошибка сохранения цен по заказу:', error);
alert(`Ошибка сохранения цен: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleAddQuote = async () => {
try {
const response = await authFetch(`/api/office/orders/${order.id}/quotes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
supplierName: quoteFormData.supplierName,
supplierContact: quoteFormData.supplierContact || null,
totalAmount: parseFloat(quoteFormData.totalAmount) || 0,
notes: quoteFormData.notes || null
})
});
if (response.ok) {
setShowAddQuoteModal(false);
setQuoteFormData({ supplierName: '', supplierContact: '', totalAmount: '', notes: '' });
onUpdate();
alert('Предложение добавлено');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка добавления предложения:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleSelectQuote = async (quoteId: number) => {
try {
for (const quote of quotes) {
if (quote.id !== quoteId) {
await authFetch(`/api/office/orders/${order.id}/quotes/${quote.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isSelected: false })
});
}
}
await authFetch(`/api/office/orders/${order.id}/quotes/${quoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isSelected: true })
});
const selectedQuote = quotes.find(q => q.id === quoteId);
if (selectedQuote) {
await authFetch(`/api/office/orders/${order.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
supplierName: selectedQuote.supplier_name,
supplierContact: selectedQuote.supplier_contact,
totalAmount: selectedQuote.total_amount,
status: 'approved'
})
});
}
onUpdate();
alert('Предложение выбрано');
} catch (error: any) {
console.error('Ошибка выбора предложения:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
return (
<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-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">{order.title}</h3>
<p className="text-xs text-slate-500">{order.order_number}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{editableItems.length > 0 && (
<div>
<h4 className="text-sm font-bold text-slate-700 mb-2">Позиции заказа</h4>
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
{editableItems.map((item: any) => (
<div key={item.id} className="grid grid-cols-12 items-center gap-3 text-sm">
<div className="col-span-5">
<span className="font-medium text-slate-800">
{item.request?.itemName || 'Позиция'}
</span>
</div>
<div className="col-span-2 text-right text-slate-600">
{item.quantity} {item.unit || 'шт.'}
</div>
<div className="col-span-2">
<input
type="number"
min={0}
step={0.01}
value={item.unitPrice ?? 0}
onChange={e => handleChangeItemPrice(
item.id,
parseFloat(e.target.value) || 0
)}
className="w-full px-2 py-1 border border-slate-300 rounded text-xs text-right"
placeholder="Цена за ед."
/>
</div>
<div className="col-span-3 text-right font-bold text-slate-800">
{formatCurrency(item.totalPrice ?? 0)}
</div>
</div>
))}
</div>
<div className="flex justify-end mt-3">
<button
onClick={handleSavePrices}
className="px-4 py-1.5 bg-primary-600 text-white rounded-lg text-xs font-bold hover:bg-primary-700 transition-colors"
>
Сохранить цены
</button>
</div>
</div>
)}
<div>
<div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-bold text-slate-700">Предложения от поставщиков</h4>
<button
onClick={() => setShowAddQuoteModal(true)}
className="px-3 py-1 bg-primary-600 text-white rounded-lg text-xs font-bold hover:bg-primary-700 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Добавить
</button>
</div>
{quotes.length > 0 ? (
<div className="space-y-2">
{quotes.map((quote: any) => (
<div
key={quote.id}
className={`p-3 rounded-lg border ${
quote.is_selected || quote.isSelected
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 bg-white'
}`}
>
<div className="flex justify-between items-start">
<div>
<p className="font-bold text-slate-800">{quote.supplier_name}</p>
{quote.supplier_contact && (
<p className="text-xs text-slate-500">{quote.supplier_contact}</p>
)}
<p className="text-sm font-bold text-primary-600 mt-1">
{formatCurrency(quote.total_amount)}
</p>
</div>
{!(quote.is_selected || quote.isSelected) && (
<button
onClick={() => handleSelectQuote(quote.id)}
className="px-3 py-1 bg-primary-600 text-white rounded-lg text-xs font-bold hover:bg-primary-700 transition-colors"
>
Выбрать
</button>
)}
{(quote.is_selected || quote.isSelected) && (
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-bold">
Выбрано
</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-slate-400">Нет предложений</p>
)}
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
{order.status === 'approved' && !(order.invoice_id || order.invoiceId) && (
<button
onClick={onCreateInvoice}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Receipt className="w-4 h-4" />
Поставить счет на оплату
</button>
)}
<button
onClick={onClose}
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>
{showAddQuoteModal && (
<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-md w-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Добавить предложение</h3>
<button
onClick={() => setShowAddQuoteModal(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={quoteFormData.supplierName}
onChange={(e) => setQuoteFormData({ ...quoteFormData, supplierName: 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>
<input
type="text"
value={quoteFormData.supplierContact}
onChange={(e) => setQuoteFormData({ ...quoteFormData, supplierContact: 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="number"
value={quoteFormData.totalAmount}
onChange={(e) => setQuoteFormData({ ...quoteFormData, totalAmount: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
min="0"
step="0.01"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
<textarea
value={quoteFormData.notes}
onChange={(e) => setQuoteFormData({ ...quoteFormData, 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={handleAddQuote}
disabled={!quoteFormData.supplierName || !quoteFormData.totalAmount}
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={() => setShowAddQuoteModal(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>
)}
</div>
</div>
);
};