942 lines
58 KiB
TypeScript
Executable File
942 lines
58 KiB
TypeScript
Executable File
|
||
import React, { useState, useEffect } from 'react';
|
||
import { Building, BuildingInventoryItem, PaymentInvoice, MaterialItem, District, WriteOffAct, WriteOffItem } from '../../types';
|
||
import { Package, Hammer, DoorOpen, Droplets, Trash2, Plus, ArrowDownToLine, History, Search, FileText, Check, X, Warehouse } from 'lucide-react';
|
||
import { apiClient, backendApi } from '../../services/apiClient';
|
||
|
||
export const InventoryView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [filterCategory, setFilterCategory] = useState<'all' | BuildingInventoryItem['category']>('all');
|
||
const [writeOffModal, setWriteOffModal] = useState<BuildingInventoryItem | null>(null);
|
||
const [writeOffQty, setWriteOffQty] = useState(1);
|
||
const [writeOffReason, setWriteOffReason] = useState('Расходные материалы для ремонта');
|
||
const [writeOffUnitPrice, setWriteOffUnitPrice] = useState<number>(0); // Цена за единицу при списании
|
||
|
||
// При открытии модального окна списания устанавливаем цену из товара
|
||
useEffect(() => {
|
||
if (writeOffModal) {
|
||
setWriteOffUnitPrice(writeOffModal.unitPrice || 0);
|
||
setWriteOffQty(1);
|
||
}
|
||
}, [writeOffModal]);
|
||
|
||
// Модальное окно для импорта из счетов
|
||
const [showImportModal, setShowImportModal] = useState(false);
|
||
const [paymentInvoices, setPaymentInvoices] = useState<PaymentInvoice[]>([]);
|
||
const [loadingInvoices, setLoadingInvoices] = useState(false);
|
||
const [selectedInvoice, setSelectedInvoice] = useState<PaymentInvoice | null>(null);
|
||
// Map: индекс позиции -> количество для импорта
|
||
const [selectedMaterials, setSelectedMaterials] = useState<Map<number, number>>(new Map());
|
||
|
||
// Модальное окно для взятия со склада участка
|
||
const [showDistrictWarehouseModal, setShowDistrictWarehouseModal] = useState(false);
|
||
const [districtInventory, setDistrictInventory] = useState<BuildingInventoryItem[]>([]);
|
||
const [selectedDistrictItems, setSelectedDistrictItems] = useState<Map<string, number>>(new Map()); // itemId -> quantity
|
||
const [district, setDistrict] = useState<District | null>(null);
|
||
|
||
const inventory = building.inventory || [];
|
||
|
||
const filteredItems = inventory.filter(item => {
|
||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||
const matchesFilter = filterCategory === 'all' || item.category === filterCategory;
|
||
return matchesSearch && matchesFilter;
|
||
});
|
||
|
||
const handleWriteOff = () => {
|
||
if (!writeOffModal) return;
|
||
|
||
// Используем цену из модального окна (или из товара, если не указана)
|
||
const unitPrice = writeOffUnitPrice || writeOffModal.unitPrice || 0;
|
||
const itemAmount = writeOffQty * unitPrice;
|
||
|
||
const writeOffItem: WriteOffItem = {
|
||
itemId: writeOffModal.id,
|
||
name: writeOffModal.name,
|
||
quantity: writeOffQty,
|
||
unit: writeOffModal.unit,
|
||
unitPrice: unitPrice,
|
||
amount: itemAmount,
|
||
reason: writeOffReason
|
||
};
|
||
|
||
const act: WriteOffAct = {
|
||
id: `wo-${Date.now()}`,
|
||
date: new Date().toISOString().split('T')[0],
|
||
items: [writeOffItem],
|
||
totalAmount: itemAmount, // Общая сумма списания
|
||
performer: 'Мастер участка',
|
||
reason: writeOffReason
|
||
};
|
||
|
||
setBuilding(prev => ({
|
||
...prev,
|
||
inventory: prev.inventory.map(item =>
|
||
item.id === writeOffModal.id
|
||
? { ...item, quantity: Math.max(0, item.quantity - writeOffQty) }
|
||
: item
|
||
),
|
||
writeOffHistory: [act, ...(prev.writeOffHistory || [])],
|
||
isDirty: true
|
||
}));
|
||
|
||
setWriteOffModal(null);
|
||
};
|
||
|
||
// Загрузка счетов с ТМЦ для текущего дома
|
||
useEffect(() => {
|
||
if (!showImportModal) return;
|
||
|
||
const loadInvoices = async () => {
|
||
try {
|
||
setLoadingInvoices(true);
|
||
const resp = await apiClient.get<{ invoices: PaymentInvoice[] }>(
|
||
`/finance/payment-invoices?purposeType=building&limit=200`
|
||
);
|
||
// Фильтруем: только по текущему дому и только с ТМЦ
|
||
const filtered = (resp?.invoices || []).filter((inv) =>
|
||
Array.isArray(inv.purposeBuildingIds) &&
|
||
inv.purposeBuildingIds.includes(building.id) &&
|
||
inv.itemType === 'materials' &&
|
||
inv.materialItems &&
|
||
inv.materialItems.length > 0
|
||
);
|
||
setPaymentInvoices(filtered);
|
||
} catch (e) {
|
||
console.warn('[InventoryView] Failed to load payment invoices:', e);
|
||
setPaymentInvoices([]);
|
||
} finally {
|
||
setLoadingInvoices(false);
|
||
}
|
||
};
|
||
|
||
loadInvoices();
|
||
}, [showImportModal, building.id]);
|
||
|
||
// Загрузка склада участка
|
||
useEffect(() => {
|
||
if (!showDistrictWarehouseModal || !building.districtId) return;
|
||
|
||
const loadDistrict = async () => {
|
||
try {
|
||
const districts = await backendApi.getDistricts();
|
||
const currentDistrict = districts.find(d => d.id === building.districtId);
|
||
if (currentDistrict) {
|
||
setDistrict(currentDistrict);
|
||
setDistrictInventory(currentDistrict.inventory || []);
|
||
} else {
|
||
setDistrict(null);
|
||
setDistrictInventory([]);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при загрузке участка:', error);
|
||
setDistrict(null);
|
||
setDistrictInventory([]);
|
||
}
|
||
setSelectedDistrictItems(new Map());
|
||
};
|
||
|
||
loadDistrict();
|
||
}, [showDistrictWarehouseModal, building.districtId]);
|
||
|
||
// Добавление выбранных позиций на склад участка (вместо прямого добавления в дом)
|
||
const handleImportMaterials = async () => {
|
||
if (!selectedInvoice || selectedMaterials.size === 0 || !building.districtId) return;
|
||
|
||
try {
|
||
// Получаем участок с сервера
|
||
const districts = await backendApi.getDistricts();
|
||
const currentDistrict = districts.find(d => d.id === building.districtId);
|
||
if (!currentDistrict) {
|
||
alert('Участок не найден');
|
||
return;
|
||
}
|
||
|
||
const newItems: BuildingInventoryItem[] = Array.from(selectedMaterials.entries())
|
||
.map(([index, quantity]) => {
|
||
const material = selectedInvoice.materialItems![index];
|
||
if (!material || quantity <= 0) return null;
|
||
|
||
// Определяем категорию по названию
|
||
const nameLower = material.name.toLowerCase();
|
||
let category: BuildingInventoryItem['category'] = 'material';
|
||
|
||
if (nameLower.includes('инструмент') || nameLower.includes('дрель') ||
|
||
nameLower.includes('перфоратор') || nameLower.includes('болгарка') ||
|
||
nameLower.includes('отвертка') || nameLower.includes('ключ')) {
|
||
category = 'tool';
|
||
} else if (nameLower.includes('дверь') || nameLower.includes('окно') ||
|
||
nameLower.includes('рама') || nameLower.includes('блок')) {
|
||
category = 'door';
|
||
} else if (nameLower.includes('расход') || nameLower.includes('салфетк') ||
|
||
nameLower.includes('перчатк') || nameLower.includes('скотч')) {
|
||
category = 'consumable';
|
||
}
|
||
|
||
// Рассчитываем общую стоимость для выбранного количества
|
||
const totalAmount = quantity * material.pricePerUnit;
|
||
|
||
return {
|
||
id: `inv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||
name: material.name,
|
||
category,
|
||
quantity: quantity,
|
||
unit: material.unit,
|
||
lastCheck: new Date().toISOString().split('T')[0],
|
||
source: 'invoice' as const,
|
||
unitPrice: material.pricePerUnit, // Цена за единицу (стоимость / количество)
|
||
totalAmount: totalAmount // Общая стоимость товара
|
||
};
|
||
})
|
||
.filter((item): item is BuildingInventoryItem => item !== null);
|
||
|
||
// Добавляем на склад участка (объединяем с существующими позициями)
|
||
const existingInventory = currentDistrict.inventory || [];
|
||
const updatedInventory = [...existingInventory];
|
||
|
||
newItems.forEach(newItem => {
|
||
// Ищем существующую позицию с таким же названием и единицей измерения
|
||
const existingIndex = updatedInventory.findIndex(
|
||
item => item.name === newItem.name && item.unit === newItem.unit
|
||
);
|
||
|
||
if (existingIndex >= 0) {
|
||
// Объединяем с существующей позицией
|
||
const existing = updatedInventory[existingIndex];
|
||
const newQuantity = existing.quantity + newItem.quantity;
|
||
const newTotalAmount = (existing.totalAmount || 0) + (newItem.totalAmount || 0);
|
||
// Пересчитываем цену за единицу на основе общей стоимости
|
||
const newUnitPrice = newQuantity > 0 ? newTotalAmount / newQuantity : (newItem.unitPrice || existing.unitPrice || 0);
|
||
|
||
updatedInventory[existingIndex] = {
|
||
...existing,
|
||
quantity: newQuantity,
|
||
totalAmount: newTotalAmount,
|
||
unitPrice: newUnitPrice
|
||
};
|
||
} else {
|
||
// Добавляем новую позицию
|
||
updatedInventory.push(newItem);
|
||
}
|
||
});
|
||
|
||
// Сохраняем обновленный склад участка через API
|
||
await backendApi.updateDistrict(currentDistrict.id, {
|
||
inventory: updatedInventory
|
||
});
|
||
|
||
alert(`Добавлено ${newItems.length} позиций на склад участка "${currentDistrict.name}"`);
|
||
|
||
// Закрываем модальное окно и сбрасываем состояние
|
||
setShowImportModal(false);
|
||
setSelectedInvoice(null);
|
||
setSelectedMaterials(new Map());
|
||
} catch (error) {
|
||
console.error('Ошибка при сохранении на склад участка:', error);
|
||
alert('Ошибка при сохранении на склад участка');
|
||
}
|
||
};
|
||
|
||
const toggleMaterialSelection = (index: number, material: MaterialItem) => {
|
||
setSelectedMaterials(prev => {
|
||
const newMap = new Map(prev);
|
||
if (newMap.has(index)) {
|
||
newMap.delete(index);
|
||
} else {
|
||
// По умолчанию ставим максимальное количество из счета
|
||
newMap.set(index, material.quantity);
|
||
}
|
||
return newMap;
|
||
});
|
||
};
|
||
|
||
const updateMaterialQuantity = (index: number, quantity: number, maxQuantity: number) => {
|
||
const validQuantity = Math.max(0, Math.min(quantity, maxQuantity));
|
||
setSelectedMaterials(prev => {
|
||
const newMap = new Map(prev);
|
||
if (validQuantity > 0) {
|
||
newMap.set(index, validQuantity);
|
||
} else {
|
||
newMap.delete(index);
|
||
}
|
||
return newMap;
|
||
});
|
||
};
|
||
|
||
// Взятие позиций со склада участка в дом
|
||
const handleTakeFromDistrict = async () => {
|
||
if (!district || selectedDistrictItems.size === 0) return;
|
||
|
||
try {
|
||
|
||
const itemsToAdd: BuildingInventoryItem[] = [];
|
||
const updatedDistrictInventory = [...districtInventory];
|
||
|
||
selectedDistrictItems.forEach((quantity, itemId) => {
|
||
const districtItem = districtInventory.find(item => item.id === itemId);
|
||
if (!districtItem || quantity <= 0 || quantity > districtItem.quantity) return;
|
||
|
||
// Добавляем в дом
|
||
const existingItemIndex = building.inventory.findIndex(
|
||
item => item.name === districtItem.name && item.unit === districtItem.unit
|
||
);
|
||
|
||
if (existingItemIndex >= 0) {
|
||
// Объединяем с существующей позицией в доме (пересчитываем общую стоимость и цену)
|
||
setBuilding(prev => {
|
||
const existing = prev.inventory[existingItemIndex];
|
||
const newQuantity = existing.quantity + quantity;
|
||
const itemTotalAmount = quantity * (districtItem.unitPrice || 0);
|
||
const newTotalAmount = (existing.totalAmount || 0) + itemTotalAmount;
|
||
const newUnitPrice = newQuantity > 0 ? newTotalAmount / newQuantity : (districtItem.unitPrice || existing.unitPrice || 0);
|
||
|
||
return {
|
||
...prev,
|
||
inventory: prev.inventory.map((item, idx) =>
|
||
idx === existingItemIndex
|
||
? {
|
||
...item,
|
||
quantity: newQuantity,
|
||
totalAmount: newTotalAmount,
|
||
unitPrice: newUnitPrice
|
||
}
|
||
: item
|
||
),
|
||
isDirty: true
|
||
};
|
||
});
|
||
} else {
|
||
// Добавляем новую позицию в дом (сохраняем цену и общую стоимость)
|
||
const itemTotalAmount = quantity * (districtItem.unitPrice || 0);
|
||
itemsToAdd.push({
|
||
...districtItem,
|
||
id: `inv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||
quantity: quantity,
|
||
source: 'district' as const,
|
||
unitPrice: districtItem.unitPrice, // Сохраняем цену при переносе со склада участка
|
||
totalAmount: itemTotalAmount // Рассчитываем общую стоимость для перенесенного количества
|
||
});
|
||
}
|
||
|
||
// Уменьшаем количество на складе участка и пересчитываем общую стоимость
|
||
const districtItemIndex = updatedDistrictInventory.findIndex(item => item.id === itemId);
|
||
if (districtItemIndex >= 0) {
|
||
const existing = updatedDistrictInventory[districtItemIndex];
|
||
const newQuantity = existing.quantity - quantity;
|
||
|
||
if (newQuantity > 0) {
|
||
// Пересчитываем общую стоимость пропорционально оставшемуся количеству
|
||
const unitPrice = existing.unitPrice || 0;
|
||
const newTotalAmount = newQuantity * unitPrice;
|
||
|
||
updatedDistrictInventory[districtItemIndex] = {
|
||
...existing,
|
||
quantity: newQuantity,
|
||
totalAmount: newTotalAmount
|
||
};
|
||
} else {
|
||
updatedDistrictInventory.splice(districtItemIndex, 1);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Обновляем склад участка через API
|
||
await backendApi.updateDistrict(district.id, {
|
||
inventory: updatedDistrictInventory
|
||
});
|
||
setDistrictInventory(updatedDistrictInventory);
|
||
|
||
// Добавляем новые позиции в дом
|
||
if (itemsToAdd.length > 0) {
|
||
setBuilding(prev => ({
|
||
...prev,
|
||
inventory: [...prev.inventory, ...itemsToAdd],
|
||
isDirty: true
|
||
}));
|
||
}
|
||
|
||
// Закрываем модальное окно
|
||
setShowDistrictWarehouseModal(false);
|
||
setSelectedDistrictItems(new Map());
|
||
} catch (error) {
|
||
console.error('Ошибка при взятии со склада участка:', error);
|
||
alert('Ошибка при взятии со склада участка');
|
||
}
|
||
};
|
||
|
||
const toggleDistrictItemSelection = (itemId: string, item: BuildingInventoryItem) => {
|
||
setSelectedDistrictItems(prev => {
|
||
const newMap = new Map(prev);
|
||
if (newMap.has(itemId)) {
|
||
newMap.delete(itemId);
|
||
} else {
|
||
newMap.set(itemId, item.quantity);
|
||
}
|
||
return newMap;
|
||
});
|
||
};
|
||
|
||
const updateDistrictItemQuantity = (itemId: string, quantity: number, maxQuantity: number) => {
|
||
const validQuantity = Math.max(0, Math.min(quantity, maxQuantity));
|
||
setSelectedDistrictItems(prev => {
|
||
const newMap = new Map(prev);
|
||
if (validQuantity > 0) {
|
||
newMap.set(itemId, validQuantity);
|
||
} else {
|
||
newMap.delete(itemId);
|
||
}
|
||
return newMap;
|
||
});
|
||
};
|
||
|
||
const categories = [
|
||
{ id: 'all', label: 'Все', icon: Package },
|
||
{ id: 'material', label: 'Материалы', icon: Droplets },
|
||
{ id: 'tool', label: 'Инструменты', icon: Hammer },
|
||
{ id: 'door', label: 'Двери/Окна', icon: DoorOpen },
|
||
{ id: 'consumable', label: 'Расходники', icon: Package },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Stats Header */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||
<p className="text-[10px] text-slate-400 font-bold uppercase">Всего позиций</p>
|
||
<p className="text-xl font-black text-slate-800">{inventory.length}</p>
|
||
</div>
|
||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||
<p className="text-[10px] text-slate-400 font-bold uppercase">Списано в этом мес.</p>
|
||
<p className="text-xl font-black text-red-600">{(building.writeOffHistory || []).length}</p>
|
||
</div>
|
||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||
<p className="text-[10px] text-slate-400 font-bold uppercase">Сумма списаний</p>
|
||
<p className="text-xl font-black text-red-600">
|
||
{(building.writeOffHistory || []).reduce((sum, wo) => sum + (wo.totalAmount || 0), 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter Bar */}
|
||
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-2">
|
||
{categories.map(cat => (
|
||
<button
|
||
key={cat.id}
|
||
onClick={() => setFilterCategory(cat.id as any)}
|
||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold whitespace-nowrap transition-all ${filterCategory === cat.id ? 'bg-primary-600 text-white shadow-md shadow-primary-200' : 'bg-white text-slate-500 border border-slate-200 hover:border-primary-300'}`}
|
||
>
|
||
<cat.icon className="w-3.5 h-3.5"/>
|
||
{cat.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Search and Add Button */}
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск по инвентарю..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm bg-white"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowDistrictWarehouseModal(true)}
|
||
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-xs flex items-center gap-2 hover:bg-emerald-700 transition-all shadow-md shadow-emerald-200"
|
||
>
|
||
<Warehouse className="w-4 h-4"/> Взять со склада участка
|
||
</button>
|
||
<button
|
||
onClick={() => setShowImportModal(true)}
|
||
className="px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-xs flex items-center gap-2 hover:bg-primary-700 transition-all shadow-md shadow-primary-200"
|
||
>
|
||
<FileText className="w-4 h-4"/> Добавить из счета
|
||
</button>
|
||
</div>
|
||
|
||
{/* Inventory List */}
|
||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm text-left">
|
||
<thead className="bg-slate-50 text-slate-500 font-bold border-b border-slate-200 text-[10px] uppercase tracking-wider">
|
||
<tr>
|
||
<th className="p-4">Наименование</th>
|
||
<th className="p-4 text-center">Кол-во</th>
|
||
<th className="p-4 text-center">Цена за ед.</th>
|
||
<th className="p-4 text-center">Общая стоимость</th>
|
||
<th className="p-4 text-center">Источник</th>
|
||
<th className="p-4 text-right">Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{filteredItems.map(item => (
|
||
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
|
||
<td className="p-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-slate-100 rounded-lg text-slate-400">
|
||
{item.category === 'tool' ? <Hammer className="w-4 h-4"/> : item.category === 'door' ? <DoorOpen className="w-4 h-4"/> : <Package className="w-4 h-4"/>}
|
||
</div>
|
||
<div>
|
||
<p className="font-bold text-slate-700">{item.name}</p>
|
||
<p className="text-[10px] text-slate-400 uppercase">{item.category}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="p-4 text-center">
|
||
<span className={`px-2 py-1 rounded text-xs font-bold ${item.quantity < 3 && item.category === 'consumable' ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-700'}`}>
|
||
{item.quantity} {item.unit}
|
||
</span>
|
||
</td>
|
||
<td className="p-4 text-center">
|
||
{item.unitPrice ? (
|
||
<span className="px-2 py-1 rounded text-xs font-bold bg-blue-50 text-blue-700">
|
||
{item.unitPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽/{item.unit}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-slate-400">—</span>
|
||
)}
|
||
</td>
|
||
<td className="p-4 text-center">
|
||
{item.totalAmount ? (
|
||
<span className="px-2 py-1 rounded text-xs font-bold bg-green-50 text-green-700">
|
||
{item.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-slate-400">—</span>
|
||
)}
|
||
</td>
|
||
<td className="p-4 text-center">
|
||
<span className={`px-2 py-1 rounded text-xs font-bold ${
|
||
item.source === 'district'
|
||
? 'bg-emerald-50 text-emerald-700'
|
||
: item.source === 'invoice'
|
||
? 'bg-blue-50 text-blue-700'
|
||
: 'bg-slate-100 text-slate-500'
|
||
}`}>
|
||
{item.source === 'district' ? 'Склад участка' : item.source === 'invoice' ? 'Из счета' : 'Не указан'}
|
||
</span>
|
||
</td>
|
||
<td className="p-4 text-right">
|
||
<button
|
||
onClick={() => setWriteOffModal(item)}
|
||
className="text-[10px] font-bold text-red-600 bg-red-50 px-3 py-1.5 rounded-lg border border-red-100 hover:bg-red-100 transition-colors flex items-center gap-1 ml-auto"
|
||
>
|
||
<ArrowDownToLine className="w-3.5 h-3.5"/> Списать
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{filteredItems.length === 0 && (
|
||
<div className="p-12 text-center text-slate-400 italic">Позиции не найдены</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Write-off History */}
|
||
<div>
|
||
<h3 className="font-bold text-slate-700 text-sm mb-3 px-1 flex items-center gap-2">
|
||
<History className="w-4 h-4"/> История списаний
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{(building.writeOffHistory || []).map(wo => (
|
||
<div key={wo.id} className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-xs">
|
||
<div className="flex justify-between items-start mb-2">
|
||
<div className="flex-1">
|
||
<span className="font-bold text-slate-800">{wo.items[0].name}</span>
|
||
<span className="text-slate-500 ml-2">({wo.items[0].quantity} {wo.items[0].unit})</span>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="font-black text-red-600 text-sm">
|
||
{wo.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||
</div>
|
||
<div className="text-slate-400 font-mono text-[10px] mt-1">{wo.date}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-between items-center mt-2">
|
||
<p className="text-slate-500 italic text-[11px]">Причина: {wo.items[0].reason || wo.reason || 'Не указана'}</p>
|
||
<p className="text-[10px] text-slate-400">Отв: {wo.performer}</p>
|
||
</div>
|
||
{wo.items[0].unitPrice > 0 && (
|
||
<p className="text-[10px] text-slate-400 mt-1">
|
||
Цена: {wo.items[0].unitPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽/{wo.items[0].unit}
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
{(building.writeOffHistory || []).length === 0 && (
|
||
<p className="text-center text-slate-400 italic text-xs py-4">Списаний еще не было</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Write-off Modal */}
|
||
{writeOffModal && (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => {
|
||
setWriteOffModal(null);
|
||
setWriteOffQty(1);
|
||
setWriteOffUnitPrice(writeOffModal.unitPrice || 0);
|
||
}}>
|
||
<div className="bg-white rounded-2xl w-full max-w-sm p-6 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
|
||
<h3 className="text-lg font-bold text-slate-800 mb-2">Списание со склада дома</h3>
|
||
<p className="text-sm text-slate-500 mb-6">{writeOffModal.name}</p>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Количество</label>
|
||
<div className="flex items-center gap-4">
|
||
<input
|
||
type="range" min="1" max={writeOffModal.quantity}
|
||
value={writeOffQty}
|
||
onChange={e => setWriteOffQty(Number(e.target.value))}
|
||
className="flex-1 accent-primary-600"
|
||
/>
|
||
<span className="text-lg font-black text-slate-800 w-12 text-center">{writeOffQty}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Цена за единицу (₽)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={writeOffUnitPrice}
|
||
onChange={e => setWriteOffUnitPrice(Number(e.target.value))}
|
||
placeholder={writeOffModal.unitPrice ? writeOffModal.unitPrice.toString() : "0.00"}
|
||
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 text-sm outline-none focus:ring-2 focus:ring-primary-500"
|
||
/>
|
||
{writeOffModal.unitPrice && (
|
||
<p className="text-xs text-slate-400 mt-1">Текущая цена: {writeOffModal.unitPrice.toLocaleString('ru-RU')} ₽</p>
|
||
)}
|
||
</div>
|
||
<div className="bg-red-50 p-3 rounded-xl border border-red-100">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-xs font-bold text-red-700 uppercase">Сумма списания:</span>
|
||
<span className="text-lg font-black text-red-700">
|
||
{(writeOffQty * writeOffUnitPrice).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Причина списания</label>
|
||
<textarea
|
||
value={writeOffReason}
|
||
onChange={e => setWriteOffReason(e.target.value)}
|
||
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 text-sm outline-none focus:ring-2 focus:ring-primary-500 min-h-[80px]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-8 flex gap-3">
|
||
<button onClick={() => {
|
||
setWriteOffModal(null);
|
||
setWriteOffQty(1);
|
||
setWriteOffUnitPrice(writeOffModal.unitPrice || 0);
|
||
}} className="flex-1 py-3 text-slate-600 font-bold bg-slate-100 rounded-xl">Отмена</button>
|
||
<button onClick={handleWriteOff} className="flex-1 py-3 text-white font-bold bg-red-600 rounded-xl hover:bg-red-700">Списать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Import from Invoice Modal */}
|
||
{showImportModal && (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setShowImportModal(false)}>
|
||
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-slate-800">Добавить инвентарь из счета</h3>
|
||
<p className="text-sm text-slate-500 mt-1">Выберите счет с ТМЦ и позиции для добавления</p>
|
||
</div>
|
||
<button onClick={() => setShowImportModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||
<X className="w-5 h-5 text-slate-400"/>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto p-6">
|
||
{loadingInvoices ? (
|
||
<div className="text-center py-12 text-slate-400">Загрузка счетов...</div>
|
||
) : paymentInvoices.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-400">
|
||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-20"/>
|
||
<p>Нет счетов с ТМЦ для этого дома</p>
|
||
</div>
|
||
) : !selectedInvoice ? (
|
||
<div className="space-y-3">
|
||
<h4 className="font-bold text-slate-700 text-sm mb-4">Выберите счет:</h4>
|
||
{paymentInvoices.map((inv) => (
|
||
<div
|
||
key={inv.id}
|
||
onClick={() => setSelectedInvoice(inv)}
|
||
className="p-4 border border-slate-200 rounded-xl hover:border-primary-400 hover:bg-primary-50/20 cursor-pointer transition-all"
|
||
>
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<div className="font-bold text-slate-800">{inv.invoiceNumber}</div>
|
||
<div className="text-sm text-slate-600 mt-1">{inv.contractorName}</div>
|
||
<div className="text-xs text-slate-400 mt-1">
|
||
{inv.materialItems?.length || 0} позиций ТМЦ • {new Date(inv.createdAt).toLocaleDateString('ru-RU')}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="font-black text-slate-800">{Number(inv.totalAmount || 0).toLocaleString('ru-RU')} ₽</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
|
||
<div>
|
||
<h4 className="font-bold text-slate-800">{selectedInvoice.invoiceNumber}</h4>
|
||
<p className="text-sm text-slate-500">{selectedInvoice.contractorName}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedInvoice(null);
|
||
setSelectedMaterials(new Map());
|
||
}}
|
||
className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1"
|
||
>
|
||
<X className="w-4 h-4"/> Выбрать другой счет
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<h5 className="font-bold text-slate-700 text-sm">Выберите позиции и укажите количество:</h5>
|
||
{selectedInvoice.materialItems?.map((material, index) => {
|
||
const isSelected = selectedMaterials.has(index);
|
||
const selectedQty = selectedMaterials.get(index) || 0;
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
className={`p-4 border-2 rounded-xl transition-all ${
|
||
isSelected
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-slate-200 hover:border-slate-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex items-start gap-3 flex-1">
|
||
<div
|
||
onClick={() => toggleMaterialSelection(index, material)}
|
||
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 cursor-pointer ${
|
||
isSelected
|
||
? 'bg-primary-500 border-primary-500'
|
||
: 'border-slate-300'
|
||
}`}
|
||
>
|
||
{isSelected && (
|
||
<Check className="w-3 h-3 text-white"/>
|
||
)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="font-bold text-slate-800">{material.name}</div>
|
||
<div className="text-sm text-slate-500 mt-1">
|
||
В счете: {material.quantity} {material.unit} × {material.pricePerUnit.toLocaleString('ru-RU')} ₽ = {material.amount.toLocaleString('ru-RU')} ₽
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isSelected && (
|
||
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
<label className="text-xs text-slate-500 font-bold whitespace-nowrap">Кол-во:</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max={material.quantity}
|
||
value={selectedQty}
|
||
onChange={(e) => {
|
||
const val = Number(e.target.value);
|
||
updateMaterialQuantity(index, val, material.quantity);
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-20 px-2 py-1 border border-slate-300 rounded-lg text-sm font-bold text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||
/>
|
||
<span className="text-xs text-slate-500">{material.unit}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedInvoice && selectedMaterials.size > 0 && (
|
||
<div className="p-6 border-t border-slate-200 bg-slate-50 flex justify-between items-center">
|
||
<div className="text-sm text-slate-600">
|
||
Выбрано позиций: <span className="font-bold text-slate-800">{selectedMaterials.size}</span>
|
||
{' • '}
|
||
Всего единиц: <span className="font-bold text-slate-800">
|
||
{Array.from(selectedMaterials.values()).reduce((sum, qty) => sum + qty, 0)}
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setShowImportModal(false);
|
||
setSelectedInvoice(null);
|
||
setSelectedMaterials(new Map());
|
||
}}
|
||
className="px-4 py-2 text-slate-600 font-bold bg-white border border-slate-200 rounded-xl hover:bg-slate-50"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={handleImportMaterials}
|
||
className="px-4 py-2 bg-primary-600 text-white font-bold rounded-xl hover:bg-primary-700 flex items-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4"/> Добавить выбранные
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Take from District Warehouse Modal */}
|
||
{showDistrictWarehouseModal && (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setShowDistrictWarehouseModal(false)}>
|
||
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-slate-800">Взять со склада участка</h3>
|
||
<p className="text-sm text-slate-500 mt-1">
|
||
{district ? `Склад участка: ${district.name}` : 'Участок не найден'}
|
||
</p>
|
||
</div>
|
||
<button onClick={() => setShowDistrictWarehouseModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||
<X className="w-5 h-5 text-slate-400"/>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto p-6">
|
||
{!district ? (
|
||
<div className="text-center py-12 text-slate-400">
|
||
<Warehouse className="w-12 h-12 mx-auto mb-3 opacity-20"/>
|
||
<p>Участок не найден</p>
|
||
</div>
|
||
) : districtInventory.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-400">
|
||
<Warehouse className="w-12 h-12 mx-auto mb-3 opacity-20"/>
|
||
<p>Склад участка пуст</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<h5 className="font-bold text-slate-700 text-sm mb-4">Выберите позиции и укажите количество:</h5>
|
||
{districtInventory.map((item) => {
|
||
const isSelected = selectedDistrictItems.has(item.id);
|
||
const selectedQty = selectedDistrictItems.get(item.id) || 0;
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`p-4 border-2 rounded-xl transition-all ${
|
||
isSelected
|
||
? 'border-emerald-500 bg-emerald-50'
|
||
: 'border-slate-200 hover:border-slate-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex items-start gap-3 flex-1">
|
||
<div
|
||
onClick={() => toggleDistrictItemSelection(item.id, item)}
|
||
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 cursor-pointer ${
|
||
isSelected
|
||
? 'bg-emerald-500 border-emerald-500'
|
||
: 'border-slate-300'
|
||
}`}
|
||
>
|
||
{isSelected && (
|
||
<Check className="w-3 h-3 text-white"/>
|
||
)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="font-bold text-slate-800">{item.name}</div>
|
||
<div className="text-sm text-slate-500 mt-1">
|
||
На складе: {item.quantity} {item.unit}
|
||
</div>
|
||
{item.unitPrice && (
|
||
<div className="text-xs text-blue-600 mt-1">
|
||
Цена: {item.unitPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽/{item.unit}
|
||
</div>
|
||
)}
|
||
{item.totalAmount && (
|
||
<div className="text-xs text-green-600 mt-1 font-bold">
|
||
Общая стоимость: {item.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-slate-400 mt-1 uppercase">
|
||
{item.category}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isSelected && (
|
||
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
<label className="text-xs text-slate-500 font-bold whitespace-nowrap">Кол-во:</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max={item.quantity}
|
||
value={selectedQty}
|
||
onChange={(e) => {
|
||
const val = Number(e.target.value);
|
||
updateDistrictItemQuantity(item.id, val, item.quantity);
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-20 px-2 py-1 border border-slate-300 rounded-lg text-sm font-bold text-slate-800 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
/>
|
||
<span className="text-xs text-slate-500">{item.unit}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{district && selectedDistrictItems.size > 0 && (
|
||
<div className="p-6 border-t border-slate-200 bg-slate-50 flex justify-between items-center">
|
||
<div className="text-sm text-slate-600">
|
||
Выбрано позиций: <span className="font-bold text-slate-800">{selectedDistrictItems.size}</span>
|
||
{' • '}
|
||
Всего единиц: <span className="font-bold text-slate-800">
|
||
{Array.from(selectedDistrictItems.values()).reduce((sum, qty) => sum + qty, 0)}
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setShowDistrictWarehouseModal(false);
|
||
setSelectedDistrictItems(new Map());
|
||
}}
|
||
className="px-4 py-2 text-slate-600 font-bold bg-white border border-slate-200 rounded-xl hover:bg-slate-50"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={handleTakeFromDistrict}
|
||
className="px-4 py-2 bg-emerald-600 text-white font-bold rounded-xl hover:bg-emerald-700 flex items-center gap-2"
|
||
>
|
||
<Warehouse className="w-4 h-4"/> Взять выбранные
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|