Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели SupplyOrder и соответствующие резолверы для поддержки логистики. Реализованы компоненты уведомлений для отображения статуса логистических заявок и поставок. Оптимизирован интерфейс для улучшения пользовательского опыта, добавлены логи для диагностики запросов. Обновлены GraphQL схемы и мутации для поддержки новых функциональных возможностей.

This commit is contained in:
Veronika Smirnova
2025-08-03 17:04:29 +03:00
parent a33adda9d7
commit 8407ca397c
34 changed files with 5382 additions and 1795 deletions

View File

@ -99,14 +99,44 @@ export function CreateFulfillmentConsumablesSupplyPage() {
GET_MY_COUNTERPARTIES
);
// ОТЛАДКА: Логируем состояние перед запросом товаров
console.log("🔍 ДИАГНОСТИКА ЗАПРОСА ТОВАРОВ:", {
selectedSupplier: selectedSupplier
? {
id: selectedSupplier.id,
name: selectedSupplier.name || selectedSupplier.fullName,
type: selectedSupplier.type,
}
: null,
skipQuery: !selectedSupplier,
productSearchQuery,
});
// Загружаем товары для выбранного поставщика
const { data: productsData, loading: productsLoading } = useQuery(
GET_ALL_PRODUCTS,
{
skip: !selectedSupplier,
variables: { search: productSearchQuery || null, category: null },
}
);
const {
data: productsData,
loading: productsLoading,
error: productsError,
} = useQuery(GET_ALL_PRODUCTS, {
skip: !selectedSupplier,
variables: { search: productSearchQuery || null, category: null },
onCompleted: (data) => {
console.log("✅ GET_ALL_PRODUCTS COMPLETED:", {
totalProducts: data?.allProducts?.length || 0,
products:
data?.allProducts?.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
orgId: p.organization?.id,
orgName: p.organization?.name,
})) || [],
});
},
onError: (error) => {
console.error("❌ GET_ALL_PRODUCTS ERROR:", error);
},
});
// Мутация для создания заказа поставки расходников
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
@ -117,9 +147,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
).filter((org: FulfillmentConsumableSupplier) => org.type === "WHOLESALE");
// Фильтруем только логистические компании
const logisticsPartners = (
counterpartiesData?.myCounterparties || []
).filter((org: FulfillmentConsumableSupplier) => org.type === "LOGIST");
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
(org: FulfillmentConsumableSupplier) => org.type === "LOGIST"
);
// Фильтруем поставщиков по поисковому запросу
const filteredSuppliers = consumableSuppliers.filter(
@ -150,6 +180,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
}
: null,
productsLoading,
productsError: productsError?.message,
allProductsCount: productsData?.allProducts?.length || 0,
supplierProductsCount: supplierProducts.length,
allProducts:
@ -160,14 +191,20 @@ export function CreateFulfillmentConsumablesSupplyPage() {
organizationName: p.organization.name,
type: p.type || "NO_TYPE",
})) || [],
supplierProducts: supplierProducts.map((p) => ({
supplierProductsDetails: supplierProducts.slice(0, 5).map((p) => ({
id: p.id,
name: p.name,
organizationId: p.organization.id,
organizationName: p.organization.name,
})),
});
}, [selectedSupplier, productsData, productsLoading, supplierProducts]);
}, [
selectedSupplier,
productsData,
productsLoading,
productsError,
supplierProducts.length,
]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
@ -198,10 +235,13 @@ export function CreateFulfillmentConsumablesSupplyPage() {
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
if (quantity > 0) {
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0);
const availableStock =
(product.stock || product.quantity || 0) - (product.ordered || 0);
if (quantity > availableStock) {
toast.error(`❌ Недостаточно остатков!\оступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`);
toast.error(
`❌ Недостаточно остатков!\оступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`
);
return;
}
}
@ -265,7 +305,15 @@ export function CreateFulfillmentConsumablesSupplyPage() {
!deliveryDate ||
!selectedLogistics
) {
toast.error("Заполните все обязательные поля");
toast.error(
"Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика"
);
return;
}
// Дополнительная проверка ID логистики
if (!selectedLogistics.id) {
toast.error("Выберите логистическую компанию");
return;
}
@ -279,7 +327,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
deliveryDate: deliveryDate,
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
fulfillmentCenterId: user?.organization?.id,
logisticsPartnerId: selectedLogistics?.id,
logisticsPartnerId: selectedLogistics.id,
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
items: selectedConsumables.map((consumable) => ({
@ -574,15 +622,19 @@ export function CreateFulfillmentConsumablesSupplyPage() {
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
{(() => {
const totalStock = product.stock || product.quantity || 0;
const totalStock =
product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const availableStock =
totalStock - orderedStock;
if (availableStock <= 0) {
return (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
<div className="text-center">
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
<div className="text-red-400 font-bold text-xs">
НЕТ В НАЛИЧИИ
</div>
</div>
</div>
);
@ -636,10 +688,12 @@ export function CreateFulfillmentConsumablesSupplyPage() {
)}
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
{(() => {
const totalStock = product.stock || product.quantity || 0;
const totalStock =
product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const availableStock =
totalStock - orderedStock;
if (availableStock <= 0) {
return (
<Badge className="bg-red-500/30 text-red-300 border-red-500/50 text-xs px-2 py-1 animate-pulse">
@ -663,19 +717,26 @@ export function CreateFulfillmentConsumablesSupplyPage() {
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
<div className="text-right">
{(() => {
const totalStock = product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const totalStock =
product.stock ||
product.quantity ||
0;
const orderedStock =
product.ordered || 0;
const availableStock =
totalStock - orderedStock;
return (
<div className="flex flex-col items-end">
<span className={`text-xs font-medium ${
availableStock <= 0
? 'text-red-400'
: availableStock <= 10
? 'text-yellow-400'
: 'text-white/80'
}`}>
<span
className={`text-xs font-medium ${
availableStock <= 0
? "text-red-400"
: availableStock <= 10
? "text-yellow-400"
: "text-white/80"
}`}
>
Доступно: {availableStock}
</span>
{orderedStock > 0 && (
@ -693,10 +754,12 @@ export function CreateFulfillmentConsumablesSupplyPage() {
{/* Управление количеством */}
<div className="flex flex-col items-center space-y-2 mt-auto">
{(() => {
const totalStock = product.stock || product.quantity || 0;
const totalStock =
product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const availableStock =
totalStock - orderedStock;
return (
<div className="flex items-center space-x-2">
<Button
@ -713,81 +776,92 @@ export function CreateFulfillmentConsumablesSupplyPage() {
>
<Minus className="h-3 w-3" />
</Button>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={
selectedQuantity === 0
? ""
: selectedQuantity.toString()
}
onChange={(e) => {
let inputValue = e.target.value;
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={
selectedQuantity === 0
? ""
: selectedQuantity.toString()
}
onChange={(e) => {
let inputValue = e.target.value;
// Удаляем все нецифровые символы
inputValue = inputValue.replace(
/[^0-9]/g,
""
);
// Удаляем все нецифровые символы
inputValue = inputValue.replace(
/[^0-9]/g,
""
);
// Удаляем ведущие нули
inputValue = inputValue.replace(
/^0+/,
""
);
// Удаляем ведущие нули
inputValue = inputValue.replace(
/^0+/,
""
);
// Если строка пустая после удаления нулей, устанавливаем 0
const numericValue =
inputValue === ""
? 0
: parseInt(inputValue);
// Если строка пустая после удаления нулей, устанавливаем 0
const numericValue =
inputValue === ""
? 0
: parseInt(inputValue);
// Ограничиваем значение максимумом доступного остатка
const clampedValue = Math.min(
numericValue,
availableStock,
99999
);
// Ограничиваем значение максимумом доступного остатка
const clampedValue = Math.min(
numericValue,
availableStock,
99999
);
updateConsumableQuantity(
product.id,
clampedValue
);
}}
onBlur={(e) => {
// При потере фокуса, если поле пустое, устанавливаем 0
if (e.target.value === "") {
updateConsumableQuantity(
product.id,
0
);
}
}}
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
placeholder="0"
/>
updateConsumableQuantity(
product.id,
clampedValue
);
}}
onBlur={(e) => {
// При потере фокуса, если поле пустое, устанавливаем 0
if (e.target.value === "") {
updateConsumableQuantity(
product.id,
0
);
}
}}
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
placeholder="0"
/>
<Button
variant="ghost"
size="sm"
onClick={() =>
updateConsumableQuantity(
product.id,
Math.min(selectedQuantity + 1, availableStock, 99999)
Math.min(
selectedQuantity + 1,
availableStock,
99999
)
)
}
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
selectedQuantity >= availableStock || availableStock <= 0
? 'text-white/30 cursor-not-allowed'
: 'text-white/60 hover:text-white hover:bg-white/20'
selectedQuantity >=
availableStock ||
availableStock <= 0
? "text-white/30 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/20"
}`}
disabled={selectedQuantity >= availableStock || availableStock <= 0}
disabled={
selectedQuantity >=
availableStock ||
availableStock <= 0
}
title={
availableStock <= 0
? 'Товар отсутствует на складе'
: selectedQuantity >= availableStock
? `Максимум доступно: ${availableStock}`
: 'Увеличить количество'
availableStock <= 0
? "Товар отсутствует на складе"
: selectedQuantity >=
availableStock
? `Максимум доступно: ${availableStock}`
: "Увеличить количество"
}
>
<Plus className="h-3 w-3" />
@ -903,7 +977,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
value={selectedLogistics?.id || ""}
onChange={(e) => {
const logisticsId = e.target.value;
const logistics = logisticsPartners.find(p => p.id === logisticsId);
const logistics = logisticsPartners.find(
(p) => p.id === logisticsId
);
setSelectedLogistics(logistics || null);
}}
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
@ -912,8 +988,8 @@ export function CreateFulfillmentConsumablesSupplyPage() {
Выберите логистику
</option>
{logisticsPartners.map((partner) => (
<option
key={partner.id}
<option
key={partner.id}
value={partner.id}
className="bg-gray-800 text-white"
>
@ -922,8 +998,18 @@ export function CreateFulfillmentConsumablesSupplyPage() {
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<svg
className="w-4 h-4 text-white/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>