Реализован функционал просмотра заявок покупателей на возврат от Wildberries API в фулфилмент-складе. Добавлена интеграция с WB API /api/v1/claims для получения заявок от всех партнеров-селлеров. Создан полнофункциональный интерфейс с поиском, фильтрацией по статусам, детальным просмотром заявок и отображением медиафайлов от покупателей.
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -25,6 +25,7 @@ import {
|
||||
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
|
||||
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
|
||||
} from "@/graphql/queries";
|
||||
import { WbReturnClaims } from "./wb-return-claims";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Package,
|
||||
@ -182,6 +183,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [expandedStores, setExpandedStores] = useState<Set<string>>(new Set());
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [showReturnClaims, setShowReturnClaims] = useState(false);
|
||||
const [showAdditionalValues, setShowAdditionalValues] = useState(true);
|
||||
|
||||
// Загружаем данные из GraphQL
|
||||
@ -1258,6 +1260,22 @@ export function FulfillmentWarehouseDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// Если показываем заявки на возврат, отображаем соответствующий компонент
|
||||
if (showReturnClaims) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-2 py-2 overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<div className="h-full bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl">
|
||||
<WbReturnClaims onBack={() => setShowReturnClaims(false)} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
@ -1354,6 +1372,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
?.percentChange
|
||||
}
|
||||
description="К обработке"
|
||||
onClick={() => setShowReturnClaims(true)}
|
||||
/>
|
||||
<StatCard
|
||||
title="Расходники селлеров"
|
||||
|
425
src/components/fulfillment-warehouse/wb-return-claims.tsx
Normal file
425
src/components/fulfillment-warehouse/wb-return-claims.tsx
Normal file
@ -0,0 +1,425 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Image as ImageIcon,
|
||||
Play,
|
||||
Calendar,
|
||||
Package,
|
||||
DollarSign,
|
||||
Building2,
|
||||
} from "lucide-react";
|
||||
import { GET_WB_RETURN_CLAIMS } from "@/graphql/queries";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
|
||||
// Типы данных
|
||||
interface WbReturnClaim {
|
||||
id: string;
|
||||
claimType: number;
|
||||
status: number;
|
||||
statusEx: number;
|
||||
nmId: number;
|
||||
userComment: string;
|
||||
wbComment?: string;
|
||||
dt: string;
|
||||
imtName: string;
|
||||
orderDt: string;
|
||||
dtUpdate: string;
|
||||
photos: string[];
|
||||
videoPaths: string[];
|
||||
actions: string[];
|
||||
price: number;
|
||||
currencyCode: string;
|
||||
srid: string;
|
||||
sellerOrganization: {
|
||||
id: string;
|
||||
name: string;
|
||||
inn: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WbReturnClaimsResponse {
|
||||
claims: WbReturnClaim[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Функции для форматирования
|
||||
const getStatusText = (status: number, statusEx: number) => {
|
||||
const statusMap: { [key: number]: string } = {
|
||||
1: "Новая",
|
||||
2: "Рассматривается",
|
||||
3: "Одобрена",
|
||||
4: "Отклонена",
|
||||
5: "Возврат завершен",
|
||||
};
|
||||
return statusMap[status] || `Статус ${status}`;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
const colorMap: { [key: number]: string } = {
|
||||
1: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
2: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
3: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
4: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
5: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||
};
|
||||
return colorMap[status] || "bg-gray-500/20 text-gray-300 border-gray-500/30";
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("ru-RU").format(price);
|
||||
};
|
||||
|
||||
interface WbReturnClaimsProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function WbReturnClaims({ onBack }: WbReturnClaimsProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isArchive, setIsArchive] = useState(false);
|
||||
const [selectedClaim, setSelectedClaim] = useState<WbReturnClaim | null>(null);
|
||||
|
||||
const { data, loading, error, refetch } = useQuery<{
|
||||
wbReturnClaims: WbReturnClaimsResponse;
|
||||
}>(GET_WB_RETURN_CLAIMS, {
|
||||
variables: {
|
||||
isArchive,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
errorPolicy: "all",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
|
||||
const claims = data?.wbReturnClaims?.claims || [];
|
||||
const total = data?.wbReturnClaims?.total || 0;
|
||||
|
||||
// Отладочный вывод
|
||||
console.log("WB Claims Debug:", {
|
||||
isArchive,
|
||||
loading,
|
||||
error: error?.message,
|
||||
total,
|
||||
claimsCount: claims.length,
|
||||
hasData: !!data,
|
||||
});
|
||||
|
||||
// Фильтрация заявок по поисковому запросу
|
||||
const filteredClaims = claims.filter((claim) =>
|
||||
claim.imtName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
claim.sellerOrganization.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
claim.nmId.toString().includes(searchQuery) ||
|
||||
claim.userComment.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Заголовок */}
|
||||
<div className="flex-shrink-0 p-6 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Назад
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Заявки покупателей на возврат
|
||||
</h2>
|
||||
<p className="text-white/70">
|
||||
Всего заявок: {total} | Показано: {filteredClaims.length} | Режим: {isArchive ? "Архив" : "Активные"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры и поиск */}
|
||||
<div className="flex-shrink-0 p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Поиск по товару, селлеру, артикулу..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={!isArchive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setIsArchive(false)}
|
||||
className={
|
||||
!isArchive
|
||||
? "bg-white/20 text-white"
|
||||
: "text-white/70 hover:text-white"
|
||||
}
|
||||
>
|
||||
Активные
|
||||
</Button>
|
||||
<Button
|
||||
variant={isArchive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setIsArchive(true)}
|
||||
className={
|
||||
isArchive
|
||||
? "bg-white/20 text-white"
|
||||
: "text-white/70 hover:text-white"
|
||||
}
|
||||
>
|
||||
Архив
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список заявок */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-300 mb-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="font-medium">Ошибка загрузки данных</span>
|
||||
</div>
|
||||
<p className="text-red-200 text-sm">
|
||||
{error.message || "Не удалось получить заявки от Wildberries API"}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-red-300 hover:text-red-200"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-white/50" />
|
||||
<span className="ml-2 text-white/70">Загрузка заявок...</span>
|
||||
</div>
|
||||
) : filteredClaims.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="h-16 w-16 mx-auto text-white/30 mb-4" />
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
Заявки не найдены
|
||||
</h3>
|
||||
<p className="text-white/60 mb-4">
|
||||
{searchQuery
|
||||
? "Попробуйте изменить критерии поиска"
|
||||
: "Новых заявок на возврат пока нет"}
|
||||
</p>
|
||||
{!searchQuery && total === 0 && (
|
||||
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-4 max-w-md mx-auto">
|
||||
<p className="text-blue-300 text-sm">
|
||||
💡 Заявки отображаются только от партнеров-селлеров с настроенными Wildberries API ключами
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredClaims.map((claim) => (
|
||||
<Card
|
||||
key={claim.id}
|
||||
className="bg-white/10 border-white/20 p-4 hover:bg-white/15 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedClaim(claim)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Badge className={getStatusColor(claim.status)}>
|
||||
{getStatusText(claim.status, claim.statusEx)}
|
||||
</Badge>
|
||||
<span className="text-sm text-white/60">
|
||||
№{claim.nmId}
|
||||
</span>
|
||||
<span className="text-sm text-white/60">
|
||||
{formatDistanceToNow(new Date(claim.dt), {
|
||||
addSuffix: true,
|
||||
locale: ru,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-white font-medium mb-2">
|
||||
{claim.imtName}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-white/70 mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{claim.sellerOrganization.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{formatPrice(claim.price)} ₽
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 text-sm line-clamp-2">
|
||||
{claim.userComment}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{claim.photos.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-white/60">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
{claim.photos.length}
|
||||
</div>
|
||||
)}
|
||||
{claim.videoPaths.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-white/60">
|
||||
<Play className="h-3 w-3" />
|
||||
{claim.videoPaths.length}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="text-white/70 hover:text-white">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Диалог детального просмотра */}
|
||||
<Dialog open={selectedClaim !== null} onOpenChange={() => setSelectedClaim(null)}>
|
||||
<DialogContent className="bg-gray-900/95 border-white/20 text-white max-w-2xl">
|
||||
{selectedClaim && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Badge className={getStatusColor(selectedClaim.status)}>
|
||||
{getStatusText(selectedClaim.status, selectedClaim.statusEx)}
|
||||
</Badge>
|
||||
<span>Заявка №{selectedClaim.nmId}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/70">
|
||||
{selectedClaim.imtName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-white/60">Дата заявки:</span>
|
||||
<p className="text-white">
|
||||
{new Date(selectedClaim.dt).toLocaleString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60">Дата заказа:</span>
|
||||
<p className="text-white">
|
||||
{new Date(selectedClaim.orderDt).toLocaleString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60">Стоимость:</span>
|
||||
<p className="text-white">{formatPrice(selectedClaim.price)} ₽</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/60">SRID:</span>
|
||||
<p className="text-white">{selectedClaim.srid}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Продавец:</span>
|
||||
<p className="text-white">
|
||||
{selectedClaim.sellerOrganization.name} (ИНН: {selectedClaim.sellerOrganization.inn})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Комментарий покупателя:</span>
|
||||
<p className="text-white bg-white/5 p-3 rounded-lg mt-1">
|
||||
{selectedClaim.userComment}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedClaim.wbComment && (
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Комментарий WB:</span>
|
||||
<p className="text-white bg-blue-500/20 p-3 rounded-lg mt-1">
|
||||
{selectedClaim.wbComment}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedClaim.photos.length > 0 || selectedClaim.videoPaths.length > 0) && (
|
||||
<div>
|
||||
<span className="text-white/60 text-sm">Медиафайлы:</span>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{selectedClaim.photos.map((photo, index) => (
|
||||
<div key={index} className="flex items-center gap-1 bg-white/10 px-2 py-1 rounded text-xs">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
Фото {index + 1}
|
||||
</div>
|
||||
))}
|
||||
{selectedClaim.videoPaths.map((video, index) => (
|
||||
<div key={index} className="flex items-center gap-1 bg-white/10 px-2 py-1 rounded text-xs">
|
||||
<Play className="h-3 w-3" />
|
||||
Видео {index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -78,9 +78,11 @@ export function SuppliesDashboard() {
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab === "consumables") {
|
||||
setActiveTab("supplies");
|
||||
setActiveTab("fulfillment");
|
||||
setActiveSubTab("consumables");
|
||||
} else if (tab === "goods") {
|
||||
setActiveTab("goods");
|
||||
setActiveTab("fulfillment");
|
||||
setActiveSubTab("goods");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@ -194,36 +196,38 @@ export function SuppliesDashboard() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab("consumables")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
|
||||
className={`flex items-center justify-between text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
|
||||
activeSubTab === "consumables"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">
|
||||
Расходники селлера
|
||||
</span>
|
||||
<span className="sm:hidden">Р</span>
|
||||
<NotificationBadge
|
||||
count={pendingCount?.supplyOrders || 0}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">
|
||||
Расходники селлера
|
||||
</span>
|
||||
<span className="sm:hidden">Р</span>
|
||||
<NotificationBadge
|
||||
count={pendingCount?.supplyOrders || 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания внутри таба расходников */}
|
||||
{activeSubTab === "consumables" && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-consumables";
|
||||
}}
|
||||
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
<span className="hidden lg:inline">Создать</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания для расходников селлера */}
|
||||
{activeSubTab === "consumables" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/supplies/create-consumables";
|
||||
}}
|
||||
className="h-7 px-3 py-1 ml-2 bg-white/8 border border-white/20 hover:bg-white/12 text-xs font-medium text-white/80 hover:text-white rounded-lg transition-all duration-150 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Создать поставку</span>
|
||||
<span className="sm:hidden">Создать</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -236,57 +240,61 @@ export function SuppliesDashboard() {
|
||||
<div className="grid grid-cols-2 flex-1">
|
||||
<button
|
||||
onClick={() => setActiveSubTab("wildberries")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
|
||||
className={`flex items-center justify-between text-xs font-medium transition-all duration-150 rounded-md px-2 ${
|
||||
activeSubTab === "wildberries"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Wildberries</span>
|
||||
<span className="sm:hidden">W</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Wildberries</span>
|
||||
<span className="sm:hidden">W</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания внутри таба Wildberries */}
|
||||
{activeSubTab === "wildberries" && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-wildberries";
|
||||
}}
|
||||
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
<span className="hidden lg:inline">Создать</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab("ozon")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
|
||||
className={`flex items-center justify-between text-xs font-medium transition-all duration-150 rounded-md px-2 ${
|
||||
activeSubTab === "ozon"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Ozon</span>
|
||||
<span className="sm:hidden">O</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Ozon</span>
|
||||
<span className="sm:hidden">O</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания внутри таба Ozon */}
|
||||
{activeSubTab === "ozon" && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-ozon";
|
||||
}}
|
||||
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
<span className="hidden lg:inline">Создать</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания для Wildberries */}
|
||||
{activeSubTab === "wildberries" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/supplies/create-wildberries";
|
||||
}}
|
||||
className="h-7 px-3 py-1 ml-2 bg-white/8 border border-white/20 hover:bg-white/12 text-xs font-medium text-white/80 hover:text-white rounded-lg transition-all duration-150 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Создать поставку</span>
|
||||
<span className="sm:hidden">Создать</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Кнопка создания для Ozon */}
|
||||
{activeSubTab === "ozon" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/supplies/create-ozon";
|
||||
}}
|
||||
className="h-7 px-3 py-1 ml-2 bg-white/8 border border-white/20 hover:bg-white/12 text-xs font-medium text-white/80 hover:text-white rounded-lg transition-all duration-150 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Создать поставку</span>
|
||||
<span className="sm:hidden">Создать</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -299,57 +307,61 @@ export function SuppliesDashboard() {
|
||||
<div className="grid grid-cols-2 flex-1">
|
||||
<button
|
||||
onClick={() => setActiveThirdTab("cards")}
|
||||
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
className={`flex items-center justify-between text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
activeThirdTab === "cards"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Карточки</span>
|
||||
<span className="sm:hidden">К</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Карточки</span>
|
||||
<span className="sm:hidden">К</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания внутри таба карточек */}
|
||||
{activeThirdTab === "cards" && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-cards";
|
||||
}}
|
||||
className="h-5 px-1.5 py-0.5 bg-white/8 border border-white/15 hover:bg-white/15 text-xs font-normal text-white/60 hover:text-white/80 rounded-sm transition-all duration-150 flex items-center gap-0.5 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-2 w-2" />
|
||||
<span className="hidden xl:inline text-xs">Создать</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveThirdTab("suppliers")}
|
||||
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
className={`flex items-center justify-between text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
activeThirdTab === "suppliers"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Поставщики</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Поставщики</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания внутри таба поставщиков */}
|
||||
{activeThirdTab === "suppliers" && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-suppliers";
|
||||
}}
|
||||
className="h-5 px-1.5 py-0.5 bg-white/8 border border-white/15 hover:bg-white/15 text-xs font-normal text-white/60 hover:text-white/80 rounded-sm transition-all duration-150 flex items-center gap-0.5 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-2 w-2" />
|
||||
<span className="hidden xl:inline text-xs">Создать</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания для карточек */}
|
||||
{activeThirdTab === "cards" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/supplies/create-cards";
|
||||
}}
|
||||
className="h-6 px-2 py-1 ml-2 bg-white/5 border border-white/15 hover:bg-white/8 text-xs font-normal text-white/60 hover:text-white/80 rounded-md transition-all duration-150 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Создать поставку</span>
|
||||
<span className="sm:hidden">Создать</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Кнопка создания для поставщиков */}
|
||||
{activeThirdTab === "suppliers" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/supplies/create-suppliers";
|
||||
}}
|
||||
className="h-6 px-2 py-1 ml-2 bg-white/5 border border-white/15 hover:bg-white/8 text-xs font-normal text-white/60 hover:text-white/80 rounded-md transition-all duration-150 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Создать поставку</span>
|
||||
<span className="sm:hidden">Создать</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,5 +1,38 @@
|
||||
import { gql } from "graphql-tag";
|
||||
|
||||
// Запрос для получения заявок покупателей на возврат от Wildberries
|
||||
export const GET_WB_RETURN_CLAIMS = gql`
|
||||
query GetWbReturnClaims($isArchive: Boolean!, $limit: Int, $offset: Int) {
|
||||
wbReturnClaims(isArchive: $isArchive, limit: $limit, offset: $offset) {
|
||||
claims {
|
||||
id
|
||||
claimType
|
||||
status
|
||||
statusEx
|
||||
nmId
|
||||
userComment
|
||||
wbComment
|
||||
dt
|
||||
imtName
|
||||
orderDt
|
||||
dtUpdate
|
||||
photos
|
||||
videoPaths
|
||||
actions
|
||||
price
|
||||
currencyCode
|
||||
srid
|
||||
sellerOrganization {
|
||||
id
|
||||
name
|
||||
inn
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ME = gql`
|
||||
query GetMe {
|
||||
me {
|
||||
|
@ -8171,6 +8171,147 @@ const wildberriesQueries = {
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Получение заявок покупателей на возврат от Wildberries от всех партнеров-селлеров
|
||||
wbReturnClaims: async (
|
||||
_: unknown,
|
||||
{ isArchive, limit, offset }: { isArchive: boolean; limit?: number; offset?: number },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Получаем текущую организацию пользователя (фулфилмент)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.organization) {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
// Проверяем, что это фулфилмент организация
|
||||
if (user.organization.type !== "FULFILLMENT") {
|
||||
throw new GraphQLError("Доступ только для фулфилмент организаций");
|
||||
}
|
||||
|
||||
// Получаем всех партнеров-селлеров с активными WB API ключами
|
||||
const partnerSellerOrgs = await prisma.counterparty.findMany({
|
||||
where: {
|
||||
organizationId: user.organization.id,
|
||||
},
|
||||
include: {
|
||||
counterparty: {
|
||||
include: {
|
||||
apiKeys: {
|
||||
where: {
|
||||
marketplace: "WILDBERRIES",
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Фильтруем только селлеров с WB API ключами
|
||||
const sellersWithWbKeys = partnerSellerOrgs.filter(
|
||||
(partner) =>
|
||||
partner.counterparty.type === "SELLER" &&
|
||||
partner.counterparty.apiKeys.length > 0
|
||||
);
|
||||
|
||||
if (sellersWithWbKeys.length === 0) {
|
||||
return {
|
||||
claims: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found ${sellersWithWbKeys.length} seller partners with WB keys`);
|
||||
|
||||
// Получаем заявки от всех селлеров параллельно
|
||||
const claimsPromises = sellersWithWbKeys.map(async (partner) => {
|
||||
const wbApiKey = partner.counterparty.apiKeys[0].apiKey;
|
||||
const wbService = new WildberriesService(wbApiKey);
|
||||
|
||||
try {
|
||||
const claimsResponse = await wbService.getClaims({
|
||||
isArchive,
|
||||
limit: Math.ceil((limit || 50) / sellersWithWbKeys.length), // Распределяем лимит между селлерами
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Добавляем информацию о селлере к каждой заявке
|
||||
const claimsWithSeller = claimsResponse.claims.map((claim) => ({
|
||||
...claim,
|
||||
sellerOrganization: {
|
||||
id: partner.counterparty.id,
|
||||
name: partner.counterparty.name || "Неизвестная организация",
|
||||
inn: partner.counterparty.inn || "",
|
||||
},
|
||||
}));
|
||||
|
||||
console.log(`Got ${claimsWithSeller.length} claims from seller ${partner.counterparty.name}`);
|
||||
return claimsWithSeller;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching claims for seller ${partner.counterparty.name}:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const allClaims = (await Promise.all(claimsPromises)).flat();
|
||||
console.log(`Total claims aggregated: ${allClaims.length}`);
|
||||
|
||||
// Сортируем по дате создания (новые первыми)
|
||||
allClaims.sort((a, b) => new Date(b.dt).getTime() - new Date(a.dt).getTime());
|
||||
|
||||
// Применяем пагинацию
|
||||
const paginatedClaims = allClaims.slice(offset || 0, (offset || 0) + (limit || 50));
|
||||
console.log(`Paginated claims: ${paginatedClaims.length}`);
|
||||
|
||||
// Преобразуем в формат фронтенда
|
||||
const transformedClaims = paginatedClaims.map((claim) => ({
|
||||
id: claim.id,
|
||||
claimType: claim.claim_type,
|
||||
status: claim.status,
|
||||
statusEx: claim.status_ex,
|
||||
nmId: claim.nm_id,
|
||||
userComment: claim.user_comment || "",
|
||||
wbComment: claim.wb_comment || null,
|
||||
dt: claim.dt,
|
||||
imtName: claim.imt_name,
|
||||
orderDt: claim.order_dt,
|
||||
dtUpdate: claim.dt_update,
|
||||
photos: claim.photos || [],
|
||||
videoPaths: claim.video_paths || [],
|
||||
actions: claim.actions || [],
|
||||
price: claim.price,
|
||||
currencyCode: claim.currency_code,
|
||||
srid: claim.srid,
|
||||
sellerOrganization: claim.sellerOrganization,
|
||||
}));
|
||||
|
||||
console.log(`Returning ${transformedClaims.length} transformed claims to frontend`);
|
||||
|
||||
return {
|
||||
claims: transformedClaims,
|
||||
total: allClaims.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching WB return claims:", error);
|
||||
throw new GraphQLError(
|
||||
error instanceof Error ? error.message : "Ошибка получения заявок на возврат"
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Резолверы для внешней рекламы
|
||||
|
@ -118,6 +118,13 @@ export const typeDefs = gql`
|
||||
# Список кампаний Wildberries
|
||||
getWildberriesCampaignsList: WildberriesCampaignsListResponse!
|
||||
|
||||
# Заявки покупателей на возврат от Wildberries (для фулфилмента)
|
||||
wbReturnClaims(
|
||||
isArchive: Boolean!
|
||||
limit: Int
|
||||
offset: Int
|
||||
): WbReturnClaimsResponse!
|
||||
|
||||
# Типы для внешней рекламы
|
||||
getExternalAds(dateFrom: String!, dateTo: String!): ExternalAdsResponse!
|
||||
|
||||
@ -1338,6 +1345,39 @@ export const typeDefs = gql`
|
||||
): WBWarehouseCacheResponse!
|
||||
}
|
||||
|
||||
# Типы для заявок на возврат WB
|
||||
type WbReturnClaim {
|
||||
id: String!
|
||||
claimType: Int!
|
||||
status: Int!
|
||||
statusEx: Int!
|
||||
nmId: Int!
|
||||
userComment: String!
|
||||
wbComment: String
|
||||
dt: String!
|
||||
imtName: String!
|
||||
orderDt: String!
|
||||
dtUpdate: String!
|
||||
photos: [String!]!
|
||||
videoPaths: [String!]!
|
||||
actions: [String!]!
|
||||
price: Int!
|
||||
currencyCode: String!
|
||||
srid: String!
|
||||
sellerOrganization: WbSellerOrganization!
|
||||
}
|
||||
|
||||
type WbSellerOrganization {
|
||||
id: String!
|
||||
name: String!
|
||||
inn: String!
|
||||
}
|
||||
|
||||
type WbReturnClaimsResponse {
|
||||
claims: [WbReturnClaim!]!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
# Типы для статистики склада фулфилмента
|
||||
type FulfillmentWarehouseStats {
|
||||
products: WarehouseStatsItem!
|
||||
|
Reference in New Issue
Block a user