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