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

3098 lines
183 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { authFetch } from '../../services/apiClient';
import { 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>
);
};