Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели 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

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useQuery, useMutation } from "@apollo/client";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useAuth } from "@/hooks/useAuth";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -77,6 +78,7 @@ interface SelectedConsumable {
export function CreateConsumablesSupplyPage() {
const router = useRouter();
const { user } = useAuth();
const { getSidebarMargin } = useSidebar();
const [selectedSupplier, setSelectedSupplier] =
useState<ConsumableSupplier | null>(null);
@ -88,6 +90,8 @@ export function CreateConsumablesSupplyPage() {
const [deliveryDate, setDeliveryDate] = useState("");
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] =
useState<ConsumableSupplier | null>(null);
const [selectedLogistics, setSelectedLogistics] =
useState<ConsumableSupplier | null>(null);
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
// Загружаем контрагентов-поставщиков расходников
@ -117,6 +121,11 @@ export function CreateConsumablesSupplyPage() {
counterpartiesData?.myCounterparties || []
).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT");
// Фильтруем логистические компании
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
(org: ConsumableSupplier) => org.type === "LOGIST"
);
// Фильтруем поставщиков по поисковому запросу
const filteredSuppliers = consumableSuppliers.filter(
(supplier: ConsumableSupplier) =>
@ -218,19 +227,82 @@ export function CreateConsumablesSupplyPage() {
selectedConsumables.length === 0 ||
!deliveryDate
) {
toast.error("Заполните все обязательные поля");
toast.error(
"Заполните все обязательные поля: поставщик, расходники и дата доставки"
);
return;
}
// Для селлеров требуется выбор фулфилмент-центра
// TODO: Добавить проверку типа текущей организации
if (!selectedFulfillmentCenter) {
toast.error("Выберите фулфилмент-центр для доставки");
return;
}
// Логистика опциональна - может выбрать селлер или оставить фулфилменту
if (selectedLogistics && !selectedLogistics.id) {
toast.error("Некорректно выбрана логистическая компания");
return;
}
// Дополнительные проверки
if (!selectedFulfillmentCenter.id) {
toast.error("ID фулфилмент-центра не найден");
return;
}
if (!selectedSupplier.id) {
toast.error("ID поставщика не найден");
return;
}
if (selectedConsumables.length === 0) {
toast.error("Не выбраны расходники");
return;
}
// Проверяем дату
const deliveryDateObj = new Date(deliveryDate);
if (isNaN(deliveryDateObj.getTime())) {
toast.error("Некорректная дата поставки");
return;
}
setIsCreatingSupply(true);
// 🔍 ОТЛАДКА: проверяем текущего пользователя
console.log("👤 Текущий пользователь:", {
userId: user?.id,
phone: user?.phone,
organizationId: user?.organization?.id,
organizationType: user?.organization?.type,
organizationName:
user?.organization?.name || user?.organization?.fullName,
});
console.log("🚀 Создаем поставку с данными:", {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
fulfillmentCenterId: selectedFulfillmentCenter.id,
logisticsPartnerId: selectedLogistics?.id,
hasLogistics: !!selectedLogistics?.id,
consumableType: "SELLER_CONSUMABLES",
itemsCount: selectedConsumables.length,
mutationInput: {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
fulfillmentCenterId: selectedFulfillmentCenter.id,
...(selectedLogistics?.id
? { logisticsPartnerId: selectedLogistics.id }
: {}),
consumableType: "SELLER_CONSUMABLES",
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
quantity: consumable.selectedQuantity,
})),
},
});
try {
const result = await createSupplyOrder({
variables: {
@ -238,6 +310,12 @@ export function CreateConsumablesSupplyPage() {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
fulfillmentCenterId: selectedFulfillmentCenter.id,
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: селлер может выбрать или оставить фулфилменту
...(selectedLogistics?.id
? { logisticsPartnerId: selectedLogistics.id }
: {}),
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
consumableType: "SELLER_CONSUMABLES", // Расходники селлеров
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
quantity: consumable.selectedQuantity,
@ -270,7 +348,21 @@ export function CreateConsumablesSupplyPage() {
}
} catch (error) {
console.error("Error creating consumables supply:", error);
toast.error("Ошибка при создании поставки расходников");
// Детальная диагностика ошибки
if (error instanceof Error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
name: error.name,
});
// Показываем конкретную ошибку пользователю
toast.error(`Ошибка: ${error.message}`);
} else {
console.error("Unknown error:", error);
toast.error("Ошибка при создании поставки расходников");
}
} finally {
setIsCreatingSupply(false);
}
@ -764,7 +856,7 @@ export function CreateConsumablesSupplyPage() {
);
setSelectedFulfillmentCenter(center || null);
}}
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
required
>
<option value="" className="bg-gray-800 text-white">
@ -782,8 +874,73 @@ export function CreateConsumablesSupplyPage() {
</option>
))}
</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>
</div>
</div>
</div>
{/* БЛОК ВЫБОРА ЛОГИСТИЧЕСКОЙ КОМПАНИИ */}
<div className="mb-3">
<label className="text-white/60 text-xs mb-1 block">
Логистическая компания:
<span className="text-white/40 ml-1">(опционально)</span>
</label>
<div className="relative">
<select
value={selectedLogistics?.id || ""}
onChange={(e) => {
const logisticsId = e.target.value;
const logistics = logisticsPartners.find(
(p) => p.id === logisticsId
);
setSelectedLogistics(logistics || null);
}}
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
>
<option value="" className="bg-gray-800 text-white">
Выберите логистику или оставьте фулфилменту
</option>
{logisticsPartners.map((partner) => (
<option
key={partner.id}
value={partner.id}
className="bg-gray-800 text-white"
>
{partner.name || partner.fullName || "Логистика"}
</option>
))}
</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>
</div>
</div>
</div>
<div className="mb-3">
<label className="text-white/60 text-xs mb-1 block">
Дата поставки:

View File

@ -42,7 +42,15 @@ interface SupplyOrderItem {
interface SupplyOrder {
id: string;
deliveryDate: string;
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
status:
| "PENDING"
| "SUPPLIER_APPROVED"
| "CONFIRMED"
| "LOGISTICS_CONFIRMED"
| "SHIPPED"
| "IN_TRANSIT"
| "DELIVERED"
| "CANCELLED";
totalAmount: number;
totalItems: number;
createdAt: string;
@ -102,10 +110,22 @@ export function SellerSupplyOrdersTab() {
label: "Ожидает одобрения",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
SUPPLIER_APPROVED: {
label: "Одобрена поставщиком",
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
},
CONFIRMED: {
label: "Одобрена",
label: "Подтверждена",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
LOGISTICS_CONFIRMED: {
label: "Готова к отправке",
color: "bg-teal-500/20 text-teal-300 border-teal-500/30",
},
SHIPPED: {
label: "Отправлена",
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
},
IN_TRANSIT: {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
@ -119,7 +139,16 @@ export function SellerSupplyOrdersTab() {
color: "bg-red-500/20 text-red-300 border-red-500/30",
},
};
const { label, color } = statusMap[status];
const config = statusMap[status as keyof typeof statusMap];
if (!config) {
// Fallback для неизвестных статусов
return (
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{status}
</Badge>
);
}
const { label, color } = config;
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
};

View File

@ -42,7 +42,27 @@ export function SuppliesDashboard() {
});
const pendingCount = pendingData?.pendingSuppliesCount;
const hasPendingItems = pendingCount && pendingCount.total > 0;
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
const hasPendingItems = (() => {
if (!pendingCount) return false;
switch (user?.organization?.type) {
case "SELLER":
// Селлеры не получают уведомления о поставках - только отслеживают статус
return false;
case "WHOLESALE":
// Поставщики видят только входящие заказы, не заявки на партнерство
return pendingCount.incomingSupplierOrders > 0;
case "FULFILLMENT":
// Фулфилмент видит только поставки к обработке, не заявки на партнерство
return pendingCount.supplyOrders > 0;
case "LOGIST":
// Логистика видит только логистические заявки, не заявки на партнерство
return pendingCount.logisticsOrders > 0;
default:
return pendingCount.total > 0;
}
})();
// Автоматически открываем нужную вкладку при загрузке
useEffect(() => {
@ -69,32 +89,33 @@ export function SuppliesDashboard() {
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
У вас есть {pendingCount.total} элемент
{pendingCount.total > 1
? pendingCount.total < 5
? "а"
: "ов"
: ""}
, требующ{pendingCount.total > 1 ? "их" : "ий"} одобрения:
{pendingCount.supplyOrders > 0 &&
` ${pendingCount.supplyOrders} заказ${
pendingCount.supplyOrders > 1
? pendingCount.supplyOrders < 5
? "а"
: "ов"
: ""
} поставок`}
{pendingCount.incomingRequests > 0 &&
pendingCount.supplyOrders > 0 &&
", "}
{pendingCount.incomingRequests > 0 &&
` ${pendingCount.incomingRequests} заявк${
pendingCount.incomingRequests > 1
? pendingCount.incomingRequests < 5
? "и"
: ""
: "а"
} на партнерство`}
{(() => {
switch (user?.organization?.type) {
case "WHOLESALE":
const orders = pendingCount.incomingSupplierOrders || 0;
return `У вас ${orders} входящ${
orders > 1 ? (orders < 5 ? "их" : "их") : "ий"
} заказ${
orders > 1 ? (orders < 5 ? "а" : "ов") : ""
} от клиентов, ожидающ${
orders > 1 ? "их" : "ий"
} подтверждения`;
case "FULFILLMENT":
const supplies = pendingCount.supplyOrders || 0;
return `У вас ${supplies} поставк${
supplies > 1 ? (supplies < 5 ? "и" : "ов") : "а"
} к обработке`;
case "LOGIST":
const logistics = pendingCount.logisticsOrders || 0;
return `У вас ${logistics} логистическ${
logistics > 1 ? (logistics < 5 ? "их" : "их") : "ая"
} заявк${
logistics > 1 ? (logistics < 5 ? "и" : "и") : "а"
} к подтверждению`;
default:
return `У вас есть элементы, требующие внимания`;
}
})()}
</AlertDescription>
</Alert>
)}