Merge branch 'main' of https://gittea.biveki.ru/Sfera/sfera
This commit is contained in:
5
src/app/fulfillment-warehouse/supplies/page.tsx
Normal file
5
src/app/fulfillment-warehouse/supplies/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { FulfillmentSuppliesPage } from "@/components/fulfillment-warehouse/fulfillment-supplies-page";
|
||||||
|
|
||||||
|
export default function FulfillmentWarehouseSuppliesPage() {
|
||||||
|
return <FulfillmentSuppliesPage />;
|
||||||
|
}
|
@ -28,6 +28,7 @@ import {
|
|||||||
GET_MY_COUNTERPARTIES,
|
GET_MY_COUNTERPARTIES,
|
||||||
GET_ALL_PRODUCTS,
|
GET_ALL_PRODUCTS,
|
||||||
GET_SUPPLY_ORDERS,
|
GET_SUPPLY_ORDERS,
|
||||||
|
GET_MY_SUPPLIES,
|
||||||
} from "@/graphql/queries";
|
} from "@/graphql/queries";
|
||||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||||
@ -232,7 +233,10 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
|
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.data?.createSupplyOrder?.success) {
|
if (result.data?.createSupplyOrder?.success) {
|
||||||
|
@ -36,57 +36,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// Компонент уведомлений о непринятых поставках
|
|
||||||
function PendingSuppliesAlert() {
|
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: "cache-first",
|
|
||||||
errorPolicy: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
|
|
||||||
const supplyOrdersCount =
|
|
||||||
pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
|
||||||
const incomingRequestsCount =
|
|
||||||
pendingData?.pendingSuppliesCount?.incomingRequests || 0;
|
|
||||||
|
|
||||||
if (pendingCount === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-r from-orange-500/20 to-red-500/20 backdrop-blur border-orange-400/30 p-3 mb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="p-2 bg-orange-500/20 rounded-full">
|
|
||||||
<Bell className="h-5 w-5 text-orange-300 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-orange-200 font-semibold text-sm flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
Требует вашего внимания
|
|
||||||
</h3>
|
|
||||||
<div className="text-orange-100 text-xs mt-1 space-y-1">
|
|
||||||
{supplyOrdersCount > 0 && (
|
|
||||||
<p>
|
|
||||||
• {supplyOrdersCount} поставок требуют вашего действия
|
|
||||||
(подтверждение/получение)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{incomingRequestsCount > 0 && (
|
|
||||||
<p>
|
|
||||||
• {incomingRequestsCount} заявок на партнерство ожидают ответа
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="bg-orange-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
|
||||||
{pendingCount > 99 ? "99+" : pendingCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SupplyOrder {
|
interface SupplyOrder {
|
||||||
id: string;
|
id: string;
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
@ -147,7 +96,7 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
},
|
},
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента
|
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фф)
|
||||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||||
],
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -288,9 +237,6 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Уведомления о непринятых поставках */}
|
|
||||||
<PendingSuppliesAlert />
|
|
||||||
|
|
||||||
{/* Компактная статистика */}
|
{/* Компактная статистика */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||||
|
@ -11,6 +11,8 @@ import { useQuery, useMutation } from "@apollo/client";
|
|||||||
import {
|
import {
|
||||||
GET_SUPPLY_ORDERS,
|
GET_SUPPLY_ORDERS,
|
||||||
GET_PENDING_SUPPLIES_COUNT,
|
GET_PENDING_SUPPLIES_COUNT,
|
||||||
|
GET_MY_SUPPLIES,
|
||||||
|
GET_WAREHOUSE_PRODUCTS,
|
||||||
} from "@/graphql/queries";
|
} from "@/graphql/queries";
|
||||||
import { UPDATE_SUPPLY_ORDER_STATUS } from "@/graphql/mutations";
|
import { UPDATE_SUPPLY_ORDER_STATUS } from "@/graphql/mutations";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@ -31,56 +33,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// Компонент уведомлений о непринятых поставках
|
|
||||||
function PendingSuppliesAlert() {
|
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: "cache-first",
|
|
||||||
errorPolicy: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
|
|
||||||
const supplyOrdersCount =
|
|
||||||
pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
|
||||||
const incomingRequestsCount =
|
|
||||||
pendingData?.pendingSuppliesCount?.incomingRequests || 0;
|
|
||||||
|
|
||||||
if (pendingCount === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-r from-orange-500/20 to-red-500/20 backdrop-blur border-orange-400/30 p-3 mb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="p-2 bg-orange-500/20 rounded-full">
|
|
||||||
<Bell className="h-5 w-5 text-orange-300 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-orange-200 font-semibold text-sm flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
Требует вашего внимания
|
|
||||||
</h3>
|
|
||||||
<div className="text-orange-100 text-xs mt-1 space-y-1">
|
|
||||||
{supplyOrdersCount > 0 && (
|
|
||||||
<p>
|
|
||||||
• {supplyOrdersCount} поставок требуют вашего действия
|
|
||||||
(подтверждение/получение)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{incomingRequestsCount > 0 && (
|
|
||||||
<p>
|
|
||||||
• {incomingRequestsCount} заявок на партнерство ожидают ответа
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="bg-orange-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
|
||||||
{pendingCount > 99 ? "99+" : pendingCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Интерфейс для заказа
|
// Интерфейс для заказа
|
||||||
interface SupplyOrder {
|
interface SupplyOrder {
|
||||||
@ -92,6 +45,7 @@ interface SupplyOrder {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
fulfillmentCenterId: string;
|
fulfillmentCenterId: string;
|
||||||
|
number?: number; // Порядковый номер
|
||||||
organization: {
|
organization: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -170,7 +124,11 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
|
|
||||||
// Мутация для обновления статуса заказа
|
// Мутация для обновления статуса заказа
|
||||||
const [updateSupplyOrderStatus] = useMutation(UPDATE_SUPPLY_ORDER_STATUS, {
|
const [updateSupplyOrderStatus] = useMutation(UPDATE_SUPPLY_ORDER_STATUS, {
|
||||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
|
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фф)
|
||||||
|
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||||
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Error updating supply order status:", error);
|
console.error("Error updating supply order status:", error);
|
||||||
toast.error("Ошибка при обновлении статуса заказа");
|
toast.error("Ошибка при обновлении статуса заказа");
|
||||||
@ -197,7 +155,11 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Убираем разделение на createdByUs и createdForUs, так как здесь только наши поставки
|
// Генерируем порядковые номера для заказов (сверху вниз от большего к меньшему)
|
||||||
|
const ordersWithNumbers = ourSupplyOrders.map((order, index) => ({
|
||||||
|
...order,
|
||||||
|
number: ourSupplyOrders.length - index, // Обратный порядок для новых заказов сверху
|
||||||
|
}));
|
||||||
|
|
||||||
const toggleOrderExpansion = (orderId: string) => {
|
const toggleOrderExpansion = (orderId: string) => {
|
||||||
const newExpanded = new Set(expandedOrders);
|
const newExpanded = new Set(expandedOrders);
|
||||||
@ -261,9 +223,6 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Уведомления о непринятых поставках */}
|
|
||||||
<PendingSuppliesAlert />
|
|
||||||
|
|
||||||
{/* Заголовок с кнопкой создания поставки */}
|
{/* Заголовок с кнопкой создания поставки */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -380,7 +339,7 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ourSupplyOrders.map((order: SupplyOrder) => {
|
{ordersWithNumbers.map((order: SupplyOrder) => {
|
||||||
const isOrderExpanded = expandedOrders.has(order.id);
|
const isOrderExpanded = expandedOrders.has(order.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -393,7 +352,7 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-white font-bold text-lg">
|
<span className="text-white font-bold text-lg">
|
||||||
#{order.id.slice(-8)}
|
{order.number}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -40,56 +40,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
// Компонент уведомлений о непринятых поставках
|
|
||||||
function PendingSuppliesAlert() {
|
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
|
||||||
fetchPolicy: "cache-first",
|
|
||||||
errorPolicy: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
|
|
||||||
const supplyOrdersCount =
|
|
||||||
pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
|
||||||
const incomingRequestsCount =
|
|
||||||
pendingData?.pendingSuppliesCount?.incomingRequests || 0;
|
|
||||||
|
|
||||||
if (pendingCount === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-r from-orange-500/20 to-red-500/20 backdrop-blur border-orange-400/30 p-3 mb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="p-2 bg-orange-500/20 rounded-full">
|
|
||||||
<Bell className="h-5 w-5 text-orange-300 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-orange-200 font-semibold text-sm flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
Требует вашего внимания
|
|
||||||
</h3>
|
|
||||||
<div className="text-orange-100 text-xs mt-1 space-y-1">
|
|
||||||
{supplyOrdersCount > 0 && (
|
|
||||||
<p>
|
|
||||||
• {supplyOrdersCount} поставок требуют вашего действия
|
|
||||||
(подтверждение/получение)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{incomingRequestsCount > 0 && (
|
|
||||||
<p>
|
|
||||||
• {incomingRequestsCount} заявок на партнерство ожидают ответа
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="bg-orange-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
|
||||||
{pendingCount > 99 ? "99+" : pendingCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Интерфейсы для данных
|
// Интерфейсы для данных
|
||||||
interface Employee {
|
interface Employee {
|
||||||
@ -712,9 +663,6 @@ export function FulfillmentGoodsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-2 xl:p-4">
|
<div className="h-full flex flex-col p-2 xl:p-4">
|
||||||
{/* Уведомления о непринятых поставках */}
|
|
||||||
<PendingSuppliesAlert />
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
|
@ -23,7 +23,12 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { GET_MY_COUNTERPARTIES, GET_ALL_PRODUCTS } from "@/graphql/queries";
|
import {
|
||||||
|
GET_MY_COUNTERPARTIES,
|
||||||
|
GET_ALL_PRODUCTS,
|
||||||
|
GET_SUPPLY_ORDERS,
|
||||||
|
GET_MY_SUPPLIES,
|
||||||
|
} from "@/graphql/queries";
|
||||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -94,9 +99,10 @@ export function MaterialsOrderForm() {
|
|||||||
variables: { search: null, category: null },
|
variables: { search: null, category: null },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Мутация для создания заказа поставки
|
// Мутация для создания заказа поставки
|
||||||
const [createSupplyOrder, { loading: isCreatingOrder }] = useMutation(CREATE_SUPPLY_ORDER);
|
const [createSupplyOrder, { loading: isCreatingOrder }] =
|
||||||
|
useMutation(CREATE_SUPPLY_ORDER);
|
||||||
|
|
||||||
// Фильтруем только поставщиков из партнеров
|
// Фильтруем только поставщиков из партнеров
|
||||||
const wholesalePartners = (counterpartiesData?.myCounterparties || []).filter(
|
const wholesalePartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||||
@ -178,19 +184,26 @@ export function MaterialsOrderForm() {
|
|||||||
input: {
|
input: {
|
||||||
partnerId: selectedPartner.id,
|
partnerId: selectedPartner.id,
|
||||||
deliveryDate: deliveryDate,
|
deliveryDate: deliveryDate,
|
||||||
items: selectedProducts.map(product => ({
|
items: selectedProducts.map((product) => ({
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
quantity: product.selectedQuantity
|
quantity: product.selectedQuantity,
|
||||||
}))
|
})),
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
|
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.data?.createSupplyOrder?.success) {
|
if (result.data?.createSupplyOrder?.success) {
|
||||||
toast.success("Заказ поставки создан успешно!");
|
toast.success("Заказ поставки создан успешно!");
|
||||||
router.push("/fulfillment-supplies");
|
router.push("/fulfillment-supplies");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.data?.createSupplyOrder?.message || "Ошибка при создании заказа");
|
toast.error(
|
||||||
|
result.data?.createSupplyOrder?.message ||
|
||||||
|
"Ошибка при создании заказа"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating supply order:", error);
|
console.error("Error creating supply order:", error);
|
||||||
@ -447,14 +460,20 @@ export function MaterialsOrderForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка создания заказа */}
|
{/* Кнопка создания заказа */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateOrder}
|
onClick={handleCreateOrder}
|
||||||
disabled={selectedProducts.length === 0 || !deliveryDate || isCreatingOrder}
|
disabled={
|
||||||
|
selectedProducts.length === 0 ||
|
||||||
|
!deliveryDate ||
|
||||||
|
isCreatingOrder
|
||||||
|
}
|
||||||
className="w-full mt-4 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
className="w-full mt-4 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
||||||
>
|
>
|
||||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||||
{isCreatingOrder ? "Создание заказа..." : "Создать заказ поставки"}
|
{isCreatingOrder
|
||||||
|
? "Создание заказа..."
|
||||||
|
: "Создать заказ поставки"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
297
src/components/fulfillment-warehouse/delivery-details.tsx
Normal file
297
src/components/fulfillment-warehouse/delivery-details.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Truck,
|
||||||
|
Package,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DeliveryDetailsProps } from "./types";
|
||||||
|
|
||||||
|
const DELIVERY_STATUS_CONFIG = {
|
||||||
|
delivered: {
|
||||||
|
label: "Доставлено",
|
||||||
|
color: "bg-green-500/20 text-green-300",
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
"in-transit": {
|
||||||
|
label: "В пути",
|
||||||
|
color: "bg-blue-500/20 text-blue-300",
|
||||||
|
icon: Truck,
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: "Ожидание",
|
||||||
|
color: "bg-yellow-500/20 text-yellow-300",
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
delayed: {
|
||||||
|
label: "Задержка",
|
||||||
|
color: "bg-red-500/20 text-red-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function DeliveryDetails({
|
||||||
|
supply,
|
||||||
|
deliveries,
|
||||||
|
viewMode,
|
||||||
|
getStatusConfig,
|
||||||
|
}: DeliveryDetailsProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeliveryStatusConfig = (status: string) => {
|
||||||
|
return (
|
||||||
|
DELIVERY_STATUS_CONFIG[status as keyof typeof DELIVERY_STATUS_CONFIG] ||
|
||||||
|
DELIVERY_STATUS_CONFIG.pending
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalStats = useMemo(() => {
|
||||||
|
const totalQuantity = deliveries.reduce((sum, d) => sum + d.quantity, 0);
|
||||||
|
const totalStock = deliveries.reduce((sum, d) => sum + d.currentStock, 0);
|
||||||
|
const totalCost = deliveries.reduce(
|
||||||
|
(sum, d) => sum + d.price * d.currentStock,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const avgPrice =
|
||||||
|
deliveries.length > 0
|
||||||
|
? deliveries.reduce((sum, d) => sum + d.price, 0) / deliveries.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { totalQuantity, totalStock, totalCost, avgPrice };
|
||||||
|
}, [deliveries]);
|
||||||
|
|
||||||
|
if (viewMode === "grid") {
|
||||||
|
return (
|
||||||
|
<div className="ml-6 mt-4 space-y-4">
|
||||||
|
<div className="text-sm font-medium text-white/70 uppercase tracking-wider mb-3 flex items-center space-x-2">
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
<span>История поставок ({deliveries.length})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Общая статистика */}
|
||||||
|
<Card className="glass-card p-4 bg-white/5 border-white/10">
|
||||||
|
<h4 className="text-sm font-medium text-white mb-3 flex items-center space-x-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
<span>Общая статистика</span>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Общий заказ</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(totalStats.totalQuantity)} {supply.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Общий остаток</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(totalStats.totalStock)} {supply.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Общая стоимость</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(totalStats.totalCost)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Средняя цена</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(totalStats.avgPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Список поставок */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{deliveries.map((delivery, index) => {
|
||||||
|
const deliveryStatusConfig = getDeliveryStatusConfig(
|
||||||
|
delivery.status
|
||||||
|
);
|
||||||
|
const DeliveryStatusIcon = deliveryStatusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`${delivery.id}-${index}`}
|
||||||
|
className="glass-card p-4 bg-white/5 border-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${deliveryStatusConfig.color} text-xs`}>
|
||||||
|
<DeliveryStatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{deliveryStatusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-white/60">
|
||||||
|
{new Date(delivery.createdAt).toLocaleDateString(
|
||||||
|
"ru-RU",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Остаток</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.currentStock)} {delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Заказано</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.quantity)} {delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Цена</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Стоимость</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price * delivery.currentStock)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{delivery.description !== supply.description && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-white/10">
|
||||||
|
<p className="text-white/60 text-xs">
|
||||||
|
Описание: {delivery.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List view - компактное отображение
|
||||||
|
return (
|
||||||
|
<div className="ml-6 mt-2 space-y-2">
|
||||||
|
<div className="text-xs font-medium text-white/70 uppercase tracking-wider mb-3 flex items-center space-x-2">
|
||||||
|
<Truck className="h-3 w-3" />
|
||||||
|
<span>История поставок</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deliveries.map((delivery, index) => {
|
||||||
|
const deliveryStatusConfig = getDeliveryStatusConfig(delivery.status);
|
||||||
|
const DeliveryStatusIcon = deliveryStatusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`${delivery.id}-${index}`}
|
||||||
|
className="glass-card p-3 bg-white/5 border-white/10"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-8 gap-3 items-center text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Дата</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{new Date(delivery.createdAt).toLocaleDateString("ru-RU", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Заказано</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.quantity)} {delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Поставлено</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.quantity)} {delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Отправлено</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.shippedQuantity || 0)}{" "}
|
||||||
|
{delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Остаток</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(
|
||||||
|
delivery.quantity - (delivery.shippedQuantity || 0)
|
||||||
|
)}{" "}
|
||||||
|
{delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Цена</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Стоимость</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price * delivery.quantity)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${deliveryStatusConfig.color} text-xs`}>
|
||||||
|
<DeliveryStatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{deliveryStatusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{delivery.description !== supply.description && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-white/10">
|
||||||
|
<p className="text-white/60 text-xs">
|
||||||
|
Описание: {delivery.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,412 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
import { GET_MY_SUPPLIES } from "@/graphql/queries";
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Wrench,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Новые компоненты
|
||||||
|
import { SuppliesHeader } from "./supplies-header";
|
||||||
|
import { SuppliesStats } from "./supplies-stats";
|
||||||
|
import { SuppliesGrid } from "./supplies-grid";
|
||||||
|
import { SuppliesList } from "./supplies-list";
|
||||||
|
|
||||||
|
// Типы
|
||||||
|
import {
|
||||||
|
Supply,
|
||||||
|
FilterState,
|
||||||
|
SortState,
|
||||||
|
ViewMode,
|
||||||
|
GroupBy,
|
||||||
|
StatusConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Статусы расходников с цветами
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
available: {
|
||||||
|
label: "Доступен",
|
||||||
|
color: "bg-green-500/20 text-green-300",
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
"low-stock": {
|
||||||
|
label: "Мало на складе",
|
||||||
|
color: "bg-yellow-500/20 text-yellow-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
"out-of-stock": {
|
||||||
|
label: "Нет в наличии",
|
||||||
|
color: "bg-red-500/20 text-red-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
"in-transit": {
|
||||||
|
label: "В пути",
|
||||||
|
color: "bg-blue-500/20 text-blue-300",
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
reserved: {
|
||||||
|
label: "Зарезервирован",
|
||||||
|
color: "bg-purple-500/20 text-purple-300",
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function FulfillmentSuppliesPage() {
|
||||||
|
const { getSidebarMargin } = useSidebar();
|
||||||
|
|
||||||
|
// Состояния
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
search: "",
|
||||||
|
category: "",
|
||||||
|
status: "",
|
||||||
|
supplier: "",
|
||||||
|
lowStock: false,
|
||||||
|
});
|
||||||
|
const [sort, setSort] = useState<SortState>({
|
||||||
|
field: "name",
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [groupBy, setGroupBy] = useState<GroupBy>("none");
|
||||||
|
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
const {
|
||||||
|
data: suppliesData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery(GET_MY_SUPPLIES, {
|
||||||
|
fetchPolicy: "cache-and-network",
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Ошибка загрузки расходников: " + error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const supplies: Supply[] = suppliesData?.mySupplies || [];
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
console.log("🔥🔥🔥 FULFILLMENT SUPPLIES PAGE DATA 🔥🔥🔥", {
|
||||||
|
suppliesCount: supplies.length,
|
||||||
|
supplies: supplies.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
currentStock: s.currentStock,
|
||||||
|
quantity: s.quantity,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функции
|
||||||
|
const getStatusConfig = useCallback((status: string): StatusConfig => {
|
||||||
|
return (
|
||||||
|
STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ||
|
||||||
|
STATUS_CONFIG.available
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSupplyDeliveries = useCallback(
|
||||||
|
(supply: Supply): Supply[] => {
|
||||||
|
return supplies.filter(
|
||||||
|
(s) => s.name === supply.name && s.category === supply.category
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[supplies]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Объединение одинаковых расходников
|
||||||
|
const consolidatedSupplies = useMemo(() => {
|
||||||
|
const grouped = supplies.reduce((acc, supply) => {
|
||||||
|
const key = `${supply.name}-${supply.category}`;
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
...supply,
|
||||||
|
currentStock: 0,
|
||||||
|
quantity: 0, // Общее количество поставленного (= заказанному)
|
||||||
|
price: 0,
|
||||||
|
totalCost: 0, // Общая стоимость
|
||||||
|
shippedQuantity: 0, // Общее отправленное количество
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Суммируем поставленное количество (заказано = поставлено)
|
||||||
|
acc[key].quantity += supply.quantity;
|
||||||
|
|
||||||
|
// Суммируем отправленное количество
|
||||||
|
acc[key].shippedQuantity += supply.shippedQuantity || 0;
|
||||||
|
|
||||||
|
// Остаток = Поставлено - Отправлено
|
||||||
|
// Если ничего не отправлено, то остаток = поставлено
|
||||||
|
acc[key].currentStock = acc[key].quantity - acc[key].shippedQuantity;
|
||||||
|
|
||||||
|
// Рассчитываем общую стоимость (количество × цена)
|
||||||
|
acc[key].totalCost += supply.quantity * supply.price;
|
||||||
|
|
||||||
|
// Средневзвешенная цена за единицу
|
||||||
|
if (acc[key].quantity > 0) {
|
||||||
|
acc[key].price = acc[key].totalCost / acc[key].quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Supply & { totalCost: number }>);
|
||||||
|
|
||||||
|
return Object.values(grouped);
|
||||||
|
}, [supplies]);
|
||||||
|
|
||||||
|
// Фильтрация и сортировка
|
||||||
|
const filteredAndSortedSupplies = useMemo(() => {
|
||||||
|
let filtered = consolidatedSupplies.filter((supply) => {
|
||||||
|
const matchesSearch =
|
||||||
|
supply.name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||||
|
supply.description.toLowerCase().includes(filters.search.toLowerCase());
|
||||||
|
const matchesCategory =
|
||||||
|
!filters.category || supply.category === filters.category;
|
||||||
|
const matchesStatus = !filters.status || supply.status === filters.status;
|
||||||
|
const matchesSupplier =
|
||||||
|
!filters.supplier ||
|
||||||
|
supply.supplier.toLowerCase().includes(filters.supplier.toLowerCase());
|
||||||
|
const matchesLowStock =
|
||||||
|
!filters.lowStock ||
|
||||||
|
(supply.currentStock <= supply.minStock && supply.currentStock > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
matchesSearch &&
|
||||||
|
matchesCategory &&
|
||||||
|
matchesStatus &&
|
||||||
|
matchesSupplier &&
|
||||||
|
matchesLowStock
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сортировка
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue: any = a[sort.field];
|
||||||
|
let bValue: any = b[sort.field];
|
||||||
|
|
||||||
|
if (typeof aValue === "string") {
|
||||||
|
aValue = aValue.toLowerCase();
|
||||||
|
bValue = bValue.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort.direction === "asc") {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [consolidatedSupplies, filters, sort]);
|
||||||
|
|
||||||
|
// Группировка
|
||||||
|
const groupedSupplies = useMemo(() => {
|
||||||
|
if (groupBy === "none")
|
||||||
|
return { "Все расходники": filteredAndSortedSupplies };
|
||||||
|
|
||||||
|
return filteredAndSortedSupplies.reduce((acc, supply) => {
|
||||||
|
const key = supply[groupBy] || "Без категории";
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(supply);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Supply[]>);
|
||||||
|
}, [filteredAndSortedSupplies, groupBy]);
|
||||||
|
|
||||||
|
// Обработчики
|
||||||
|
const handleSort = useCallback((field: SortState["field"]) => {
|
||||||
|
setSort((prev) => ({
|
||||||
|
field,
|
||||||
|
direction:
|
||||||
|
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSupplyExpansion = useCallback((supplyId: string) => {
|
||||||
|
setExpandedSupplies((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(supplyId)) {
|
||||||
|
newSet.delete(supplyId);
|
||||||
|
} else {
|
||||||
|
newSet.add(supplyId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
const csvData = filteredAndSortedSupplies.map((supply) => ({
|
||||||
|
Название: supply.name,
|
||||||
|
Описание: supply.description,
|
||||||
|
Категория: supply.category,
|
||||||
|
Статус: getStatusConfig(supply.status).label,
|
||||||
|
"Текущий остаток": supply.currentStock,
|
||||||
|
"Минимальный остаток": supply.minStock,
|
||||||
|
Единица: supply.unit,
|
||||||
|
Цена: supply.price,
|
||||||
|
Поставщик: supply.supplier,
|
||||||
|
"Дата создания": new Date(supply.createdAt).toLocaleDateString("ru-RU"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const csv = [
|
||||||
|
Object.keys(csvData[0]).join(","),
|
||||||
|
...csvData.map((row) => Object.values(row).join(",")),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `расходники_фф_${
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
}.csv`;
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
toast.success("Данные экспортированы в CSV");
|
||||||
|
}, [filteredAndSortedSupplies, getStatusConfig]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success("Данные обновлены");
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main
|
||||||
|
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||||
|
<div className="text-white">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main
|
||||||
|
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||||
|
<div className="text-red-300">Ошибка загрузки: {error.message}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main
|
||||||
|
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
|
{/* Заголовок и фильтры */}
|
||||||
|
<SuppliesHeader
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
groupBy={groupBy}
|
||||||
|
onGroupByChange={setGroupBy}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
showFilters={showFilters}
|
||||||
|
onToggleFilters={() => setShowFilters(!showFilters)}
|
||||||
|
onExport={handleExport}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<SuppliesStats supplies={consolidatedSupplies} />
|
||||||
|
|
||||||
|
{/* Основной контент */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groupBy === "none" ? (
|
||||||
|
// Без группировки
|
||||||
|
<>
|
||||||
|
{viewMode === "grid" && (
|
||||||
|
<SuppliesGrid
|
||||||
|
supplies={filteredAndSortedSupplies}
|
||||||
|
expandedSupplies={expandedSupplies}
|
||||||
|
onToggleExpansion={toggleSupplyExpansion}
|
||||||
|
getSupplyDeliveries={getSupplyDeliveries}
|
||||||
|
getStatusConfig={getStatusConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === "list" && (
|
||||||
|
<SuppliesList
|
||||||
|
supplies={filteredAndSortedSupplies}
|
||||||
|
expandedSupplies={expandedSupplies}
|
||||||
|
onToggleExpansion={toggleSupplyExpansion}
|
||||||
|
getSupplyDeliveries={getSupplyDeliveries}
|
||||||
|
getStatusConfig={getStatusConfig}
|
||||||
|
sort={sort}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === "analytics" && (
|
||||||
|
<div className="text-center text-white/60 py-12">
|
||||||
|
Аналитический режим будет добавлен позже
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// С группировкой
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(groupedSupplies).map(
|
||||||
|
([groupName, groupSupplies]) => (
|
||||||
|
<div key={groupName} className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center space-x-2">
|
||||||
|
<span>{groupName}</span>
|
||||||
|
<span className="text-sm text-white/60">
|
||||||
|
({groupSupplies.length})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{viewMode === "grid" && (
|
||||||
|
<SuppliesGrid
|
||||||
|
supplies={groupSupplies}
|
||||||
|
expandedSupplies={expandedSupplies}
|
||||||
|
onToggleExpansion={toggleSupplyExpansion}
|
||||||
|
getSupplyDeliveries={getSupplyDeliveries}
|
||||||
|
getStatusConfig={getStatusConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === "list" && (
|
||||||
|
<SuppliesList
|
||||||
|
supplies={groupSupplies}
|
||||||
|
expandedSupplies={expandedSupplies}
|
||||||
|
onToggleExpansion={toggleSupplyExpansion}
|
||||||
|
getSupplyDeliveries={getSupplyDeliveries}
|
||||||
|
getStatusConfig={getStatusConfig}
|
||||||
|
sort={sort}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,1840 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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 { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
import { GET_MY_SUPPLIES } from "@/graphql/queries";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
SortAsc,
|
||||||
|
SortDesc,
|
||||||
|
Package,
|
||||||
|
Wrench,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
BarChart3,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
DollarSign,
|
||||||
|
Hash,
|
||||||
|
Activity,
|
||||||
|
Layers,
|
||||||
|
PieChart,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Zap,
|
||||||
|
Target,
|
||||||
|
Sparkles,
|
||||||
|
Truck,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Типы данных
|
||||||
|
interface Supply {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
supplier: string;
|
||||||
|
minStock: number;
|
||||||
|
currentStock: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
search: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
supplier: string;
|
||||||
|
lowStock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortState {
|
||||||
|
field: keyof Supply;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статусы расходников с цветами
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
available: {
|
||||||
|
label: "Доступен",
|
||||||
|
color: "bg-green-500/20 text-green-300",
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
"low-stock": {
|
||||||
|
label: "Мало на складе",
|
||||||
|
color: "bg-yellow-500/20 text-yellow-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
"out-of-stock": {
|
||||||
|
label: "Нет в наличии",
|
||||||
|
color: "bg-red-500/20 text-red-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
"in-transit": {
|
||||||
|
label: "В пути",
|
||||||
|
color: "bg-blue-500/20 text-blue-300",
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
reserved: {
|
||||||
|
label: "Зарезервирован",
|
||||||
|
color: "bg-purple-500/20 text-purple-300",
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function FulfillmentSuppliesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { getSidebarMargin } = useSidebar();
|
||||||
|
|
||||||
|
// Состояния
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list" | "analytics">(
|
||||||
|
"grid"
|
||||||
|
);
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
search: "",
|
||||||
|
category: "",
|
||||||
|
status: "",
|
||||||
|
supplier: "",
|
||||||
|
lowStock: false,
|
||||||
|
});
|
||||||
|
const [sort, setSort] = useState<SortState>({
|
||||||
|
field: "name",
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [groupBy, setGroupBy] = useState<
|
||||||
|
"none" | "category" | "status" | "supplier"
|
||||||
|
>("none");
|
||||||
|
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
const {
|
||||||
|
data: suppliesData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery(GET_MY_SUPPLIES, {
|
||||||
|
fetchPolicy: "cache-and-network",
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Ошибка загрузки расходников: " + error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const supplies: Supply[] = suppliesData?.mySupplies || [];
|
||||||
|
|
||||||
|
// Объединение идентичных расходников
|
||||||
|
const consolidatedSupplies = useMemo(() => {
|
||||||
|
const suppliesMap = new Map<string, Supply>();
|
||||||
|
|
||||||
|
supplies.forEach((supply) => {
|
||||||
|
const key = `${supply.name}-${supply.category}-${supply.supplier}`;
|
||||||
|
|
||||||
|
if (suppliesMap.has(key)) {
|
||||||
|
const existing = suppliesMap.get(key)!;
|
||||||
|
// Суммируем количества
|
||||||
|
existing.currentStock += supply.currentStock;
|
||||||
|
existing.quantity += supply.quantity;
|
||||||
|
// Берем максимальный минимальный остаток
|
||||||
|
existing.minStock = Math.max(existing.minStock, supply.minStock);
|
||||||
|
// Обновляем статус на основе суммарного остатка
|
||||||
|
if (existing.currentStock === 0) {
|
||||||
|
existing.status = "out-of-stock";
|
||||||
|
} else if (existing.currentStock <= existing.minStock) {
|
||||||
|
existing.status = "low-stock";
|
||||||
|
} else {
|
||||||
|
existing.status = "available";
|
||||||
|
}
|
||||||
|
// Обновляем дату на более позднюю
|
||||||
|
if (new Date(supply.updatedAt) > new Date(existing.updatedAt)) {
|
||||||
|
existing.updatedAt = supply.updatedAt;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Создаем копию с правильным статусом
|
||||||
|
const consolidatedSupply = { ...supply };
|
||||||
|
if (consolidatedSupply.currentStock === 0) {
|
||||||
|
consolidatedSupply.status = "out-of-stock";
|
||||||
|
} else if (
|
||||||
|
consolidatedSupply.currentStock <= consolidatedSupply.minStock
|
||||||
|
) {
|
||||||
|
consolidatedSupply.status = "low-stock";
|
||||||
|
} else {
|
||||||
|
consolidatedSupply.status = "available";
|
||||||
|
}
|
||||||
|
suppliesMap.set(key, consolidatedSupply);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(suppliesMap.values());
|
||||||
|
}, [supplies]);
|
||||||
|
|
||||||
|
// Статистика на основе объединенных данных
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = consolidatedSupplies.length;
|
||||||
|
const available = consolidatedSupplies.filter(
|
||||||
|
(s) => s.status === "available"
|
||||||
|
).length;
|
||||||
|
const lowStock = consolidatedSupplies.filter(
|
||||||
|
(s) => s.currentStock <= s.minStock && s.currentStock > 0
|
||||||
|
).length;
|
||||||
|
const outOfStock = consolidatedSupplies.filter(
|
||||||
|
(s) => s.currentStock === 0
|
||||||
|
).length;
|
||||||
|
const inTransit = consolidatedSupplies.filter(
|
||||||
|
(s) => s.status === "in-transit"
|
||||||
|
).length;
|
||||||
|
const totalValue = consolidatedSupplies.reduce(
|
||||||
|
(sum, s) => sum + s.price * s.currentStock,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const categories = new Set(consolidatedSupplies.map((s) => s.category))
|
||||||
|
.size;
|
||||||
|
const suppliers = new Set(consolidatedSupplies.map((s) => s.supplier)).size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
available,
|
||||||
|
lowStock,
|
||||||
|
outOfStock,
|
||||||
|
inTransit,
|
||||||
|
totalValue,
|
||||||
|
categories,
|
||||||
|
suppliers,
|
||||||
|
};
|
||||||
|
}, [consolidatedSupplies]);
|
||||||
|
|
||||||
|
// Фильтрация и сортировка объединенных данных
|
||||||
|
const filteredAndSortedSupplies = useMemo(() => {
|
||||||
|
let filtered = consolidatedSupplies.filter((supply) => {
|
||||||
|
const matchesSearch =
|
||||||
|
supply.name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||||
|
supply.description
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(filters.search.toLowerCase()) ||
|
||||||
|
supply.supplier.toLowerCase().includes(filters.search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesCategory =
|
||||||
|
!filters.category || supply.category === filters.category;
|
||||||
|
const matchesStatus = !filters.status || supply.status === filters.status;
|
||||||
|
const matchesSupplier =
|
||||||
|
!filters.supplier || supply.supplier === filters.supplier;
|
||||||
|
const matchesLowStock =
|
||||||
|
!filters.lowStock || supply.currentStock <= supply.minStock;
|
||||||
|
|
||||||
|
return (
|
||||||
|
matchesSearch &&
|
||||||
|
matchesCategory &&
|
||||||
|
matchesStatus &&
|
||||||
|
matchesSupplier &&
|
||||||
|
matchesLowStock
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сортировка
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aValue = a[sort.field];
|
||||||
|
const bValue = b[sort.field];
|
||||||
|
|
||||||
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||||
|
return sort.direction === "asc"
|
||||||
|
? aValue.localeCompare(bValue)
|
||||||
|
: bValue.localeCompare(aValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === "number" && typeof bValue === "number") {
|
||||||
|
return sort.direction === "asc" ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [consolidatedSupplies, filters, sort]);
|
||||||
|
|
||||||
|
// Уникальные значения для фильтров на основе объединенных данных
|
||||||
|
const uniqueCategories = useMemo(
|
||||||
|
() => [...new Set(consolidatedSupplies.map((s) => s.category))].sort(),
|
||||||
|
[consolidatedSupplies]
|
||||||
|
);
|
||||||
|
const uniqueStatuses = useMemo(
|
||||||
|
() => [...new Set(consolidatedSupplies.map((s) => s.status))].sort(),
|
||||||
|
[consolidatedSupplies]
|
||||||
|
);
|
||||||
|
const uniqueSuppliers = useMemo(
|
||||||
|
() => [...new Set(consolidatedSupplies.map((s) => s.supplier))].sort(),
|
||||||
|
[consolidatedSupplies]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработчики
|
||||||
|
const handleSort = useCallback((field: keyof Supply) => {
|
||||||
|
setSort((prev) => ({
|
||||||
|
field,
|
||||||
|
direction:
|
||||||
|
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback(
|
||||||
|
(key: keyof FilterState, value: string | boolean) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setFilters({
|
||||||
|
search: "",
|
||||||
|
category: "",
|
||||||
|
status: "",
|
||||||
|
supplier: "",
|
||||||
|
lowStock: false,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Получение всех поставок для конкретного расходника
|
||||||
|
const getSupplyDeliveries = useCallback(
|
||||||
|
(supply: Supply) => {
|
||||||
|
const key = `${supply.name}-${supply.category}-${supply.supplier}`;
|
||||||
|
return supplies.filter(
|
||||||
|
(s) => `${s.name}-${s.category}-${s.supplier}` === key
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[supplies]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработчик разворачивания/сворачивания расходника
|
||||||
|
const toggleSupplyExpansion = useCallback((supplyId: string) => {
|
||||||
|
setExpandedSupplies((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(supplyId)) {
|
||||||
|
newSet.delete(supplyId);
|
||||||
|
} else {
|
||||||
|
newSet.add(supplyId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) =>
|
||||||
|
new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
|
||||||
|
const getStatusConfig = (status: string) =>
|
||||||
|
STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ||
|
||||||
|
STATUS_CONFIG.available;
|
||||||
|
|
||||||
|
// Группировка данных
|
||||||
|
const groupedSupplies = useMemo(() => {
|
||||||
|
if (groupBy === "none")
|
||||||
|
return { "Все расходники": filteredAndSortedSupplies };
|
||||||
|
|
||||||
|
const groups: Record<string, Supply[]> = {};
|
||||||
|
|
||||||
|
filteredAndSortedSupplies.forEach((supply) => {
|
||||||
|
const key = supply[groupBy] || "Не указано";
|
||||||
|
if (!groups[key]) groups[key] = [];
|
||||||
|
groups[key].push(supply);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [filteredAndSortedSupplies, groupBy]);
|
||||||
|
|
||||||
|
// Экспорт данных
|
||||||
|
const exportData = useCallback(
|
||||||
|
(format: "csv" | "json") => {
|
||||||
|
const data = filteredAndSortedSupplies.map((supply) => ({
|
||||||
|
Название: supply.name,
|
||||||
|
Описание: supply.description,
|
||||||
|
Категория: supply.category,
|
||||||
|
Статус: getStatusConfig(supply.status).label,
|
||||||
|
"Остаток (шт)": supply.currentStock,
|
||||||
|
"Мин. остаток (шт)": supply.minStock,
|
||||||
|
"Цена (руб)": supply.price,
|
||||||
|
Поставщик: supply.supplier,
|
||||||
|
"Дата создания": new Date(supply.createdAt).toLocaleDateString("ru-RU"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (format === "csv") {
|
||||||
|
const csv = [
|
||||||
|
Object.keys(data[0]).join(","),
|
||||||
|
...data.map((row) => Object.values(row).join(",")),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `расходники_${
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
}.csv`;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: "application/json" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `расходники_${
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
}.json`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Данные экспортированы в формате ${format.toUpperCase()}`);
|
||||||
|
},
|
||||||
|
[filteredAndSortedSupplies, getStatusConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Аналитические данные для графиков на основе объединенных данных
|
||||||
|
const analyticsData = useMemo(() => {
|
||||||
|
const categoryStats = uniqueCategories.map((category) => {
|
||||||
|
const categorySupplies = consolidatedSupplies.filter(
|
||||||
|
(s) => s.category === category
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: category,
|
||||||
|
count: categorySupplies.length,
|
||||||
|
value: categorySupplies.reduce(
|
||||||
|
(sum, s) => sum + s.price * s.currentStock,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
stock: categorySupplies.reduce((sum, s) => sum + s.currentStock, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusStats = uniqueStatuses.map((status) => {
|
||||||
|
const statusSupplies = consolidatedSupplies.filter(
|
||||||
|
(s) => s.status === status
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: getStatusConfig(status).label,
|
||||||
|
count: statusSupplies.length,
|
||||||
|
value: statusSupplies.reduce(
|
||||||
|
(sum, s) => sum + s.price * s.currentStock,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const supplierStats = uniqueSuppliers.map((supplier) => {
|
||||||
|
const supplierSupplies = consolidatedSupplies.filter(
|
||||||
|
(s) => s.supplier === supplier
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: supplier,
|
||||||
|
count: supplierSupplies.length,
|
||||||
|
value: supplierSupplies.reduce(
|
||||||
|
(sum, s) => sum + s.price * s.currentStock,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { categoryStats, statusStats, supplierStats };
|
||||||
|
}, [
|
||||||
|
consolidatedSupplies,
|
||||||
|
uniqueCategories,
|
||||||
|
uniqueStatuses,
|
||||||
|
uniqueSuppliers,
|
||||||
|
getStatusConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-smooth flex items-center justify-center">
|
||||||
|
<Card className="p-6 bg-red-500/10 border-red-500/20">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">
|
||||||
|
Ошибка загрузки
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/60 mb-4">{error.message}</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-smooth">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className={`transition-all duration-300 ${getSidebarMargin()}`}>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Хлебные крошки и заголовок */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg">
|
||||||
|
<Wrench className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span>Расходники фулфилмента</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60 mt-1">
|
||||||
|
Управление расходными материалами и инвентарем
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* Переключатель режимов просмотра */}
|
||||||
|
<div className="flex items-center bg-white/5 rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
className={`h-8 px-3 ${
|
||||||
|
viewMode === "grid"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`h-8 px-3 ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "analytics" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("analytics")}
|
||||||
|
className={`h-8 px-3 ${
|
||||||
|
viewMode === "analytics"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Группировка */}
|
||||||
|
<select
|
||||||
|
value={groupBy}
|
||||||
|
onChange={(e) => setGroupBy(e.target.value as typeof groupBy)}
|
||||||
|
className="bg-white/5 border border-white/20 rounded-md px-3 py-2 text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="none" className="bg-slate-800">
|
||||||
|
Без группировки
|
||||||
|
</option>
|
||||||
|
<option value="category" className="bg-slate-800">
|
||||||
|
По категориям
|
||||||
|
</option>
|
||||||
|
<option value="status" className="bg-slate-800">
|
||||||
|
По статусу
|
||||||
|
</option>
|
||||||
|
<option value="supplier" className="bg-slate-800">
|
||||||
|
По поставщикам
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Экспорт */}
|
||||||
|
<div className="relative group">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-white border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Экспорт
|
||||||
|
</Button>
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-48 bg-slate-800 border border-white/20 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => exportData("csv")}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-white/10 rounded flex items-center"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
||||||
|
Экспорт в CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => exportData("json")}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-white/10 rounded flex items-center"
|
||||||
|
>
|
||||||
|
<Hash className="h-4 w-4 mr-2" />
|
||||||
|
Экспорт в JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-4 mb-6">
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||||
|
<Package className="h-4 w-4 text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Всего</p>
|
||||||
|
<p className="text-lg font-bold text-white">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Доступно</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stats.available}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Мало</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stats.lowStock}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-red-500/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Нет в наличии</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stats.outOfStock}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||||
|
<Clock className="h-4 w-4 text-purple-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">В пути</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stats.inTransit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-emerald-500/20 rounded-lg">
|
||||||
|
<DollarSign className="h-4 w-4 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Стоимость</p>
|
||||||
|
<p className="text-sm font-bold text-white">
|
||||||
|
{formatCurrency(stats.totalValue)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-orange-500/20 rounded-lg">
|
||||||
|
<Layers className="h-4 w-4 text-orange-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Категории</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stats.categories}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-pink-500/20 rounded-lg">
|
||||||
|
<User className="h-4 w-4 text-pink-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Поставщики</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stats.suppliers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Панель фильтров и поиска */}
|
||||||
|
<Card className="glass-card p-4 mb-6">
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
{/* Основная строка поиска и кнопок */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по названию, описанию или поставщику..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("search", e.target.value)
|
||||||
|
}
|
||||||
|
className="pl-10 bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="text-white border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Фильтры
|
||||||
|
{(filters.category ||
|
||||||
|
filters.status ||
|
||||||
|
filters.supplier ||
|
||||||
|
filters.lowStock) && (
|
||||||
|
<Badge className="ml-2 bg-blue-500/20 text-blue-300">
|
||||||
|
Активны
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-white border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Очистить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Расширенные фильтры */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 pt-4 border-t border-white/10">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-white/70 mb-2">
|
||||||
|
Категория
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("category", e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-md px-3 py-2 text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
{uniqueCategories.map((category) => (
|
||||||
|
<option
|
||||||
|
key={category}
|
||||||
|
value={category}
|
||||||
|
className="bg-slate-800"
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-white/70 mb-2">
|
||||||
|
Статус
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("status", e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-md px-3 py-2 text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
{uniqueStatuses.map((status) => (
|
||||||
|
<option
|
||||||
|
key={status}
|
||||||
|
value={status}
|
||||||
|
className="bg-slate-800"
|
||||||
|
>
|
||||||
|
{getStatusConfig(status).label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-white/70 mb-2">
|
||||||
|
Поставщик
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.supplier}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("supplier", e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-md px-3 py-2 text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Все поставщики</option>
|
||||||
|
{uniqueSuppliers.map((supplier) => (
|
||||||
|
<option
|
||||||
|
key={supplier}
|
||||||
|
value={supplier}
|
||||||
|
className="bg-slate-800"
|
||||||
|
>
|
||||||
|
{supplier}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex items-center space-x-2 text-sm text-white">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.lowStock}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("lowStock", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-white/20 bg-white/5 text-blue-500 focus:ring-blue-500/20"
|
||||||
|
/>
|
||||||
|
<span>Только с низким остатком</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Заголовки сортировки для списочного вида */}
|
||||||
|
{viewMode === "list" && (
|
||||||
|
<Card className="glass-card p-4 mb-4">
|
||||||
|
<div className="grid grid-cols-8 gap-4 text-xs font-medium text-white/70 uppercase tracking-wider">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("name")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Название</span>
|
||||||
|
{sort.field === "name" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("category")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Категория</span>
|
||||||
|
{sort.field === "category" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("status")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Статус</span>
|
||||||
|
{sort.field === "status" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("currentStock")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Остаток</span>
|
||||||
|
{sort.field === "currentStock" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("minStock")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Мин. остаток</span>
|
||||||
|
{sort.field === "minStock" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("price")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Цена</span>
|
||||||
|
{sort.field === "price" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort("supplier")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Поставщик</span>
|
||||||
|
{sort.field === "supplier" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<span>Действия</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Список расходников */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Card key={i} className="glass-card p-4 animate-pulse">
|
||||||
|
<div className="h-4 bg-white/10 rounded mb-2"></div>
|
||||||
|
<div className="h-3 bg-white/5 rounded mb-4"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-2 bg-white/5 rounded"></div>
|
||||||
|
<div className="h-2 bg-white/5 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredAndSortedSupplies.length === 0 ? (
|
||||||
|
<Card className="glass-card p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
Расходники не найдены
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/60">
|
||||||
|
Попробуйте изменить параметры поиска или фильтрации
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === "analytics" ? (
|
||||||
|
// Аналитический режим с графиками
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Графики аналитики */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Распределение по категориям */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||||
|
<PieChart className="h-5 w-5 text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
По категориям
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analyticsData.categoryStats.map((item, index) => {
|
||||||
|
const colors = [
|
||||||
|
"bg-blue-500",
|
||||||
|
"bg-green-500",
|
||||||
|
"bg-yellow-500",
|
||||||
|
"bg-purple-500",
|
||||||
|
"bg-pink-500",
|
||||||
|
];
|
||||||
|
const color = colors[index % colors.length];
|
||||||
|
const percentage =
|
||||||
|
stats.total > 0 ? (item.count / stats.total) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.name} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white/80">{item.name}</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${color} transition-all`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/60">
|
||||||
|
<span>{percentage.toFixed(1)}%</span>
|
||||||
|
<span>{formatCurrency(item.value)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Распределение по статусам */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||||
|
<Target className="h-5 w-5 text-green-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
По статусам
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analyticsData.statusStats.map((item, index) => {
|
||||||
|
const colors = [
|
||||||
|
"bg-green-500",
|
||||||
|
"bg-yellow-500",
|
||||||
|
"bg-red-500",
|
||||||
|
"bg-blue-500",
|
||||||
|
"bg-purple-500",
|
||||||
|
];
|
||||||
|
const color = colors[index % colors.length];
|
||||||
|
const percentage =
|
||||||
|
stats.total > 0 ? (item.count / stats.total) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.name} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white/80">{item.name}</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${color} transition-all`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/60">
|
||||||
|
<span>{percentage.toFixed(1)}%</span>
|
||||||
|
<span>{formatCurrency(item.value)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ТОП поставщики */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||||
|
<Sparkles className="h-5 w-5 text-purple-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
ТОП поставщики
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analyticsData.supplierStats
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((item, index) => {
|
||||||
|
const colors = [
|
||||||
|
"bg-gold-500",
|
||||||
|
"bg-silver-500",
|
||||||
|
"bg-bronze-500",
|
||||||
|
"bg-blue-500",
|
||||||
|
"bg-green-500",
|
||||||
|
];
|
||||||
|
const color = colors[index] || "bg-gray-500";
|
||||||
|
const maxValue = Math.max(
|
||||||
|
...analyticsData.supplierStats.map((s) => s.value)
|
||||||
|
);
|
||||||
|
const percentage =
|
||||||
|
maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.name} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs font-bold text-yellow-400">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-white/80 truncate max-w-32"
|
||||||
|
title={item.name}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 transition-all`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/60">
|
||||||
|
<span>{formatCurrency(item.value)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дополнительные метрики */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-emerald-500/20 rounded-lg">
|
||||||
|
<Zap className="h-4 w-4 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Средняя цена</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{consolidatedSupplies.length > 0
|
||||||
|
? formatCurrency(
|
||||||
|
consolidatedSupplies.reduce(
|
||||||
|
(sum, s) => sum + s.price,
|
||||||
|
0
|
||||||
|
) / consolidatedSupplies.length
|
||||||
|
)
|
||||||
|
: "0 ₽"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-indigo-500/20 rounded-lg">
|
||||||
|
<Activity className="h-4 w-4 text-indigo-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Средний остаток</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{consolidatedSupplies.length > 0
|
||||||
|
? Math.round(
|
||||||
|
consolidatedSupplies.reduce(
|
||||||
|
(sum, s) => sum + s.currentStock,
|
||||||
|
0
|
||||||
|
) / consolidatedSupplies.length
|
||||||
|
)
|
||||||
|
: 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-rose-500/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-rose-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">
|
||||||
|
Критический остаток
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{
|
||||||
|
consolidatedSupplies.filter(
|
||||||
|
(s) => s.currentStock === 0
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-cyan-500/20 rounded-lg">
|
||||||
|
<TrendingUp className="h-4 w-4 text-cyan-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">Оборачиваемость</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{((stats.available / stats.total) * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : groupBy !== "none" ? (
|
||||||
|
// Группированный вид
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(groupedSupplies).map(
|
||||||
|
([groupName, groupSupplies]) => (
|
||||||
|
<Card key={groupName} className="glass-card">
|
||||||
|
<div className="p-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center space-x-2">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
<span>{groupName}</span>
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300">
|
||||||
|
{groupSupplies.length}
|
||||||
|
</Badge>
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-white/60">
|
||||||
|
Общая стоимость:{" "}
|
||||||
|
{formatCurrency(
|
||||||
|
groupSupplies.reduce(
|
||||||
|
(sum, s) => sum + s.price * s.currentStock,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{groupSupplies.map((supply) => {
|
||||||
|
const statusConfig = getStatusConfig(supply.status);
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const isLowStock =
|
||||||
|
supply.currentStock <= supply.minStock &&
|
||||||
|
supply.currentStock > 0;
|
||||||
|
const stockPercentage =
|
||||||
|
supply.minStock > 0
|
||||||
|
? (supply.currentStock / supply.minStock) * 100
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={supply.id}>
|
||||||
|
<Card
|
||||||
|
className="bg-white/5 border-white/10 p-3 hover:bg-white/10 transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={() => toggleSupplyExpansion(supply.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1 flex items-start space-x-2">
|
||||||
|
<div className="flex items-center justify-center w-4 h-4 mt-0.5">
|
||||||
|
{expandedSupplies.has(supply.id) ? (
|
||||||
|
<ChevronDown className="h-3 w-3 text-white/60" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-white/60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-white text-sm mb-1">
|
||||||
|
{supply.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-white/60 line-clamp-1">
|
||||||
|
{supply.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 text-xs">
|
||||||
|
{getSupplyDeliveries(supply).length}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
className={`${statusConfig.color} text-xs`}
|
||||||
|
>
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">
|
||||||
|
Остаток:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
isLowStock
|
||||||
|
? "text-yellow-300"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatNumber(supply.currentStock)}{" "}
|
||||||
|
{supply.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className={`h-1 rounded-full transition-all ${
|
||||||
|
stockPercentage <= 50
|
||||||
|
? "bg-red-500"
|
||||||
|
: stockPercentage <= 100
|
||||||
|
? "bg-yellow-500"
|
||||||
|
: "bg-green-500"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(
|
||||||
|
stockPercentage,
|
||||||
|
100
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Цена:</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{formatCurrency(supply.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Развернутые поставки для группированного режима */}
|
||||||
|
{expandedSupplies.has(supply.id) && (
|
||||||
|
<div className="ml-6 mt-2 space-y-2">
|
||||||
|
<div className="text-xs font-medium text-white/70 uppercase tracking-wider mb-2 flex items-center space-x-2">
|
||||||
|
<Truck className="h-3 w-3" />
|
||||||
|
<span>Поставки</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getSupplyDeliveries(supply).map((delivery, deliveryIndex) => {
|
||||||
|
const deliveryStatusConfig = getStatusConfig(delivery.status);
|
||||||
|
const DeliveryStatusIcon = deliveryStatusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={`${delivery.id}-${deliveryIndex}`} className="bg-white/10 border-white/20 p-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${deliveryStatusConfig.color} text-xs`}>
|
||||||
|
<DeliveryStatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{deliveryStatusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-white/60">
|
||||||
|
{new Date(delivery.createdAt).toLocaleDateString("ru-RU", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-white">
|
||||||
|
{formatNumber(delivery.currentStock)} {delivery.unit}
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price * delivery.currentStock)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : viewMode === "grid" ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> const statusConfig = getStatusConfig(supply.status);
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const isLowStock =
|
||||||
|
supply.currentStock <= supply.minStock &&
|
||||||
|
supply.currentStock > 0;
|
||||||
|
const stockPercentage =
|
||||||
|
supply.minStock > 0
|
||||||
|
? (supply.currentStock / supply.minStock) * 100
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={supply.id}>
|
||||||
|
{/* Основная карточка расходника */}
|
||||||
|
<Card
|
||||||
|
className="glass-card p-4 hover:bg-white/15 transition-all duration-300 cursor-pointer group"
|
||||||
|
onClick={() => toggleSupplyExpansion(supply.id)}
|
||||||
|
>
|
||||||
|
{/* Заголовок карточки */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 flex items-start space-x-2">
|
||||||
|
<div className="flex items-center justify-center w-5 h-5 mt-0.5">
|
||||||
|
{expandedSupplies.has(supply.id) ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-white/60" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-white/60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-white text-sm mb-1 group-hover:text-blue-300 transition-colors">
|
||||||
|
{supply.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-white/60 line-clamp-2">
|
||||||
|
{supply.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 text-xs">
|
||||||
|
{getSupplyDeliveries(supply).length} поставок
|
||||||
|
</Badge>
|
||||||
|
<Badge className={`${statusConfig.color} text-xs`}>
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика остатков */}
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-white/60">Остаток:</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
isLowStock ? "text-yellow-300" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatNumber(supply.currentStock)} {supply.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Прогресс-бар остатков */}
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
stockPercentage <= 50
|
||||||
|
? "bg-red-500"
|
||||||
|
: stockPercentage <= 100
|
||||||
|
? "bg-yellow-500"
|
||||||
|
: "bg-green-500"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(stockPercentage, 100)}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-white/60">Мин. остаток:</span>
|
||||||
|
<span className="text-white/80">
|
||||||
|
{formatNumber(supply.minStock)} {supply.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дополнительная информация */}
|
||||||
|
<div className="space-y-1 mb-3 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Категория:</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-white/20 text-white/80"
|
||||||
|
>
|
||||||
|
{supply.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Цена:</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{formatCurrency(supply.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Поставщик:</span>
|
||||||
|
<span
|
||||||
|
className="text-white/80 truncate max-w-24"
|
||||||
|
title={supply.supplier}
|
||||||
|
>
|
||||||
|
{supply.supplier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
<div className="flex items-center space-x-2 pt-2 border-t border-white/10">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 text-xs text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Подробнее
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Развернутые поставки */}
|
||||||
|
{expandedSupplies.has(supply.id) && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="text-xs font-medium text-white/70 uppercase tracking-wider mb-3 flex items-center space-x-2">
|
||||||
|
<Truck className="h-3 w-3" />
|
||||||
|
<span>История поставок</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getSupplyDeliveries(supply).map(
|
||||||
|
(delivery, deliveryIndex) => {
|
||||||
|
const deliveryStatusConfig = getStatusConfig(
|
||||||
|
delivery.status
|
||||||
|
);
|
||||||
|
const DeliveryStatusIcon =
|
||||||
|
deliveryStatusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`${delivery.id}-${deliveryIndex}`}
|
||||||
|
className="bg-white/5 border-white/10 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
|
<Badge
|
||||||
|
className={`${deliveryStatusConfig.color} text-xs`}
|
||||||
|
>
|
||||||
|
<DeliveryStatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{deliveryStatusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-white/60">
|
||||||
|
{new Date(
|
||||||
|
delivery.createdAt
|
||||||
|
).toLocaleDateString("ru-RU", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Остаток</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.currentStock)}{" "}
|
||||||
|
{delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">
|
||||||
|
Заказано
|
||||||
|
</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.quantity)}{" "}
|
||||||
|
{delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Цена</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">
|
||||||
|
Стоимость
|
||||||
|
</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(
|
||||||
|
delivery.price *
|
||||||
|
delivery.currentStock
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{delivery.description &&
|
||||||
|
delivery.description !==
|
||||||
|
supply.description && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-white/60 text-xs">
|
||||||
|
Описание
|
||||||
|
</p>
|
||||||
|
<p className="text-white/80 text-xs">
|
||||||
|
{delivery.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Итоговая статистика по поставкам */}
|
||||||
|
<Card className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border-blue-500/20 p-3 mt-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Всего поставок</p>
|
||||||
|
<p className="text-white font-bold">
|
||||||
|
{getSupplyDeliveries(supply).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Общая стоимость</p>
|
||||||
|
<p className="text-white font-bold">
|
||||||
|
{formatCurrency(
|
||||||
|
getSupplyDeliveries(supply).reduce(
|
||||||
|
(sum, d) => sum + d.price * d.currentStock,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Списочный вид
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredAndSortedSupplies.map((supply) => {
|
||||||
|
const statusConfig = getStatusConfig(supply.status);
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const isLowStock =
|
||||||
|
supply.currentStock <= supply.minStock &&
|
||||||
|
supply.currentStock > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={supply.id}>
|
||||||
|
<Card
|
||||||
|
className="glass-card p-4 hover:bg-white/15 transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={() => toggleSupplyExpansion(supply.id)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-8 gap-4 items-center text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex items-center justify-center w-4 h-4">
|
||||||
|
{expandedSupplies.has(supply.id) ? (
|
||||||
|
<ChevronDown className="h-3 w-3 text-white/60" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-white/60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">{supply.name}</p>
|
||||||
|
<p className="text-xs text-white/60 truncate">
|
||||||
|
{supply.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-white/20 text-white/80"
|
||||||
|
>
|
||||||
|
{supply.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 text-xs">
|
||||||
|
{getSupplyDeliveries(supply).length}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={`${statusConfig.color} text-xs`}>
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`font-medium ${
|
||||||
|
isLowStock ? "text-yellow-300" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatNumber(supply.currentStock)} {supply.unit}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-white/80">
|
||||||
|
{formatNumber(supply.minStock)} {supply.unit}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-medium text-white">
|
||||||
|
{formatCurrency(supply.price)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="text-white/80 truncate"
|
||||||
|
title={supply.supplier}
|
||||||
|
>
|
||||||
|
{supply.supplier}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Развернутые поставки для списочного режима */}
|
||||||
|
{expandedSupplies.has(supply.id) && (
|
||||||
|
<div className="ml-6 mt-2 space-y-2">
|
||||||
|
<div className="text-xs font-medium text-white/70 uppercase tracking-wider mb-3 flex items-center space-x-2">
|
||||||
|
<Truck className="h-3 w-3" />
|
||||||
|
<span>История поставок</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getSupplyDeliveries(supply).map((delivery, deliveryIndex) => {
|
||||||
|
const deliveryStatusConfig = getStatusConfig(delivery.status);
|
||||||
|
const DeliveryStatusIcon = deliveryStatusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={`${delivery.id}-${deliveryIndex}`} className="bg-white/5 border-white/10 p-3">
|
||||||
|
<div className="grid grid-cols-6 gap-4 items-center text-xs">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${deliveryStatusConfig.color} text-xs`}>
|
||||||
|
<DeliveryStatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{deliveryStatusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Дата</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{new Date(delivery.createdAt).toLocaleDateString("ru-RU", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Остаток</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.currentStock)} {delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Заказано</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatNumber(delivery.quantity)} {delivery.unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Цена</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60">Стоимость</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(delivery.price * delivery.currentStock)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{delivery.description && delivery.description !== supply.description && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-white/10">
|
||||||
|
<p className="text-white/60 text-xs">Описание: {delivery.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Пагинация (если нужна) */}
|
||||||
|
{filteredAndSortedSupplies.length > 0 && (
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Показано {filteredAndSortedSupplies.length} из{" "}
|
||||||
|
{consolidatedSupplies.length} расходников (объединено из{" "}
|
||||||
|
{supplies.length} записей)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -8,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import {
|
import {
|
||||||
GET_MY_COUNTERPARTIES,
|
GET_MY_COUNTERPARTIES,
|
||||||
@ -158,7 +160,9 @@ interface SupplyOrder {
|
|||||||
* - Контрастный цвет текста для лучшей читаемости
|
* - Контрастный цвет текста для лучшей читаемости
|
||||||
*/
|
*/
|
||||||
export function FulfillmentWarehouseDashboard() {
|
export function FulfillmentWarehouseDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
const { getSidebarMargin } = useSidebar();
|
const { getSidebarMargin } = useSidebar();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Состояния для поиска и фильтрации
|
// Состояния для поиска и фильтрации
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@ -387,6 +391,38 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Подсчитываем расходники ФФ (расходники, которые получил фулфилмент-центр)
|
||||||
|
const fulfillmentConsumablesOrders = supplyOrders.filter((order) => {
|
||||||
|
// Заказы где текущий фулфилмент-центр является получателем
|
||||||
|
const isRecipient =
|
||||||
|
order.fulfillmentCenter?.id === user?.organization?.id;
|
||||||
|
// НО создатель заказа НЕ мы (т.е. селлер создал заказ для нас)
|
||||||
|
const isCreatedByOther =
|
||||||
|
order.organization?.id !== user?.organization?.id;
|
||||||
|
// И статус DELIVERED (получено)
|
||||||
|
const isDelivered = order.status === "DELIVERED";
|
||||||
|
|
||||||
|
return isRecipient && isCreatedByOther && isDelivered;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подсчитываем общее количество расходников ФФ из доставленных заказов
|
||||||
|
const totalFulfillmentSupplies = fulfillmentConsumablesOrders.reduce(
|
||||||
|
(sum, order) => sum + (order.totalItems || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Подсчитываем изменения за сегодня (расходники ФФ, полученные сегодня)
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const fulfillmentSuppliesReceivedToday = fulfillmentConsumablesOrders
|
||||||
|
.filter((order) => {
|
||||||
|
const orderDate = new Date(order.updatedAt || order.createdAt);
|
||||||
|
orderDate.setHours(0, 0, 0, 0);
|
||||||
|
return orderDate.getTime() === today.getTime();
|
||||||
|
})
|
||||||
|
.reduce((sum, order) => sum + (order.totalItems || 0), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
products: {
|
products: {
|
||||||
current: 0, // Нет данных о готовых продуктах для продажи
|
current: 0, // Нет данных о готовых продуктах для продажи
|
||||||
@ -405,8 +441,8 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
change: 0, // Нет реальных данных об изменениях возвратов
|
change: 0, // Нет реальных данных об изменениях возвратов
|
||||||
},
|
},
|
||||||
fulfillmentSupplies: {
|
fulfillmentSupplies: {
|
||||||
current: 0, // Нет реальных данных о расходниках ФФ
|
current: totalFulfillmentSupplies, // Реальное количество расходников ФФ
|
||||||
change: 0, // Нет реальных данных об изменениях расходников ФФ
|
change: fulfillmentSuppliesReceivedToday, // Расходники ФФ, полученные сегодня
|
||||||
},
|
},
|
||||||
sellerSupplies: {
|
sellerSupplies: {
|
||||||
current: totalSellerSupplies, // Реальное количество расходников селлера из базы
|
current: totalSellerSupplies, // Реальное количество расходников селлера из базы
|
||||||
@ -422,6 +458,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
suppliesUsedToday,
|
suppliesUsedToday,
|
||||||
productsReceivedToday,
|
productsReceivedToday,
|
||||||
productsUsedToday,
|
productsUsedToday,
|
||||||
|
user?.organization?.id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Создаем структурированные данные склада на основе уникальных товаров
|
// Создаем структурированные данные склада на основе уникальных товаров
|
||||||
@ -710,7 +747,9 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
) || 1)) *
|
) || 1)) *
|
||||||
(suppliesReceivedToday - suppliesUsedToday)
|
(suppliesReceivedToday - suppliesUsedToday)
|
||||||
)
|
)
|
||||||
: Math.floor((suppliesReceivedToday - suppliesUsedToday) / totalVirtualPartners);
|
: Math.floor(
|
||||||
|
(suppliesReceivedToday - suppliesUsedToday) / totalVirtualPartners
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `virtual-partner-${index + 1}`,
|
id: `virtual-partner-${index + 1}`,
|
||||||
@ -958,18 +997,23 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
current,
|
current,
|
||||||
change,
|
change,
|
||||||
description,
|
description,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
current: number;
|
current: number;
|
||||||
change: number;
|
change: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
onClick?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const percentChange = current > 0 ? (change / current) * 100 : 0;
|
const percentChange = current > 0 ? (change / current) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`glass-card p-3 hover:bg-white/15 transition-all duration-300 relative overflow-hidden`}
|
className={`glass-card p-3 hover:bg-white/15 transition-all duration-300 relative overflow-hidden ${
|
||||||
|
onClick ? "cursor-pointer hover:scale-105" : ""
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@ -1000,18 +1044,28 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Изменения - всегда показываем */}
|
{/* Изменения - всегда показываем */}
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<div className={`flex items-center space-x-0.5 px-1 py-0.5 rounded ${
|
<div
|
||||||
change >= 0 ? 'bg-green-500/20' : 'bg-red-500/20'
|
className={`flex items-center space-x-0.5 px-1 py-0.5 rounded ${
|
||||||
}`}>
|
change >= 0 ? "bg-green-500/20" : "bg-red-500/20"
|
||||||
<span className={`text-xs font-bold ${
|
}`}
|
||||||
change >= 0 ? 'text-green-400' : 'text-red-400'
|
>
|
||||||
}`}>
|
<span
|
||||||
{change >= 0 ? '+' : ''}{change}
|
className={`text-xs font-bold ${
|
||||||
|
change >= 0 ? "text-green-400" : "text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{change >= 0 ? "+" : ""}
|
||||||
|
{change}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/60 text-[10px]">{description}</div>
|
<div className="text-white/60 text-[10px]">{description}</div>
|
||||||
|
{onClick && (
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<ChevronRight className="h-3 w-3 text-white/60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1196,6 +1250,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
current={warehouseStats.fulfillmentSupplies.current}
|
current={warehouseStats.fulfillmentSupplies.current}
|
||||||
change={warehouseStats.fulfillmentSupplies.change}
|
change={warehouseStats.fulfillmentSupplies.change}
|
||||||
description="Расходники, этикетки"
|
description="Расходники, этикетки"
|
||||||
|
onClick={() => router.push("/fulfillment-warehouse/supplies")}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Расходники селлеров"
|
title="Расходники селлеров"
|
||||||
|
47
src/components/fulfillment-warehouse/supplies-grid.tsx
Normal file
47
src/components/fulfillment-warehouse/supplies-grid.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SuppliesGridProps } from "./types";
|
||||||
|
import { SupplyCard } from "./supply-card";
|
||||||
|
import { DeliveryDetails } from "./delivery-details";
|
||||||
|
|
||||||
|
export function SuppliesGrid({
|
||||||
|
supplies,
|
||||||
|
expandedSupplies,
|
||||||
|
onToggleExpansion,
|
||||||
|
getSupplyDeliveries,
|
||||||
|
getStatusConfig,
|
||||||
|
}: SuppliesGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{supplies.map((supply) => {
|
||||||
|
const statusConfig = getStatusConfig(supply.status);
|
||||||
|
const isExpanded = expandedSupplies.has(supply.id);
|
||||||
|
const deliveries = getSupplyDeliveries(supply);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={supply.id} className="space-y-4">
|
||||||
|
{/* Основная карточка расходника */}
|
||||||
|
<SupplyCard
|
||||||
|
supply={supply}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggleExpansion={onToggleExpansion}
|
||||||
|
statusConfig={statusConfig}
|
||||||
|
getSupplyDeliveries={getSupplyDeliveries}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Развернутые поставки */}
|
||||||
|
{isExpanded && (
|
||||||
|
<DeliveryDetails
|
||||||
|
supply={supply}
|
||||||
|
deliveries={deliveries}
|
||||||
|
viewMode="grid"
|
||||||
|
getStatusConfig={getStatusConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
263
src/components/fulfillment-warehouse/supplies-header.tsx
Normal file
263
src/components/fulfillment-warehouse/supplies-header.tsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
BarChart3,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
Download,
|
||||||
|
RotateCcw,
|
||||||
|
Layers,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SuppliesHeaderProps } from "./types";
|
||||||
|
|
||||||
|
export function SuppliesHeader({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
groupBy,
|
||||||
|
onGroupByChange,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
showFilters,
|
||||||
|
onToggleFilters,
|
||||||
|
onExport,
|
||||||
|
onRefresh,
|
||||||
|
}: SuppliesHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof typeof filters, value: any) => {
|
||||||
|
onFiltersChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Заголовок страницы */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-1">
|
||||||
|
Расходники фулфилмента
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
Управление расходными материалами фулфилмент-центра
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* Экспорт данных */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onExport}
|
||||||
|
className="border-white/20 text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Экспорт
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Обновить */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="border-white/20 text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Панель управления */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск расходников..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||||
|
className="pl-10 w-64 bg-white/5 border-white/20 text-white placeholder:text-white/40 focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleFilters}
|
||||||
|
className={`border-white/20 ${
|
||||||
|
showFilters
|
||||||
|
? "bg-white/10 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Фильтры
|
||||||
|
{(filters.category ||
|
||||||
|
filters.status ||
|
||||||
|
filters.supplier ||
|
||||||
|
filters.lowStock) && (
|
||||||
|
<Badge className="ml-2 bg-blue-500/20 text-blue-300 text-xs">
|
||||||
|
{
|
||||||
|
[
|
||||||
|
filters.category,
|
||||||
|
filters.status,
|
||||||
|
filters.supplier,
|
||||||
|
filters.lowStock,
|
||||||
|
].filter(Boolean).length
|
||||||
|
}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* Переключатель режимов просмотра */}
|
||||||
|
<div className="flex items-center bg-white/5 rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("grid")}
|
||||||
|
className={`h-8 px-3 ${
|
||||||
|
viewMode === "grid"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("list")}
|
||||||
|
className={`h-8 px-3 ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "analytics" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("analytics")}
|
||||||
|
className={`h-8 px-3 ${
|
||||||
|
viewMode === "analytics"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Группировка */}
|
||||||
|
{viewMode !== "analytics" && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Layers className="h-4 w-4 text-white/60" />
|
||||||
|
<select
|
||||||
|
value={groupBy}
|
||||||
|
onChange={(e) => onGroupByChange(e.target.value as any)}
|
||||||
|
className="bg-white/5 border border-white/20 rounded-md px-3 py-1 text-sm text-white focus:border-blue-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="none">Без группировки</option>
|
||||||
|
<option value="category">По категориям</option>
|
||||||
|
<option value="status">По статусу</option>
|
||||||
|
<option value="supplier">По поставщикам</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Развернутые фильтры */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||||
|
Категория
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(e) => handleFilterChange("category", e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/20 rounded-md px-3 py-2 text-sm text-white focus:border-blue-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
<option value="packaging">Упаковка</option>
|
||||||
|
<option value="tools">Инструменты</option>
|
||||||
|
<option value="maintenance">Обслуживание</option>
|
||||||
|
<option value="office">Офисные принадлежности</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||||
|
Статус
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => handleFilterChange("status", e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/20 rounded-md px-3 py-2 text-sm text-white focus:border-blue-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="available">Доступен</option>
|
||||||
|
<option value="low-stock">Мало на складе</option>
|
||||||
|
<option value="out-of-stock">Нет в наличии</option>
|
||||||
|
<option value="in-transit">В пути</option>
|
||||||
|
<option value="reserved">Зарезервирован</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||||
|
Поставщик
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Поставщик..."
|
||||||
|
value={filters.supplier}
|
||||||
|
onChange={(e) => handleFilterChange("supplier", e.target.value)}
|
||||||
|
className="bg-white/5 border-white/20 text-white placeholder:text-white/40 focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 pt-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="lowStock"
|
||||||
|
checked={filters.lowStock}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("lowStock", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-white/20 bg-white/5 text-blue-500 focus:ring-blue-400"
|
||||||
|
/>
|
||||||
|
<label htmlFor="lowStock" className="text-sm text-white/70">
|
||||||
|
Только с низким остатком
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
174
src/components/fulfillment-warehouse/supplies-list.tsx
Normal file
174
src/components/fulfillment-warehouse/supplies-list.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { SortAsc, SortDesc, User, Calendar } from "lucide-react";
|
||||||
|
import { SuppliesListProps } from "./types";
|
||||||
|
import { DeliveryDetails } from "./delivery-details";
|
||||||
|
|
||||||
|
export function SuppliesList({
|
||||||
|
supplies,
|
||||||
|
expandedSupplies,
|
||||||
|
onToggleExpansion,
|
||||||
|
getSupplyDeliveries,
|
||||||
|
getStatusConfig,
|
||||||
|
sort,
|
||||||
|
onSort,
|
||||||
|
}: SuppliesListProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Заголовки столбцов */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="grid grid-cols-8 gap-3 text-xs font-medium text-white/70 uppercase tracking-wider">
|
||||||
|
<button
|
||||||
|
onClick={() => onSort("name")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Название</span>
|
||||||
|
{sort.field === "name" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSort("category")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Категория</span>
|
||||||
|
{sort.field === "category" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<span>Поставлено</span>
|
||||||
|
<span>Отправлено</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onSort("currentStock")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Остаток</span>
|
||||||
|
{sort.field === "currentStock" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSort("supplier")}
|
||||||
|
className="text-left flex items-center space-x-1 hover:text-white"
|
||||||
|
>
|
||||||
|
<span>Поставщик</span>
|
||||||
|
{sort.field === "supplier" &&
|
||||||
|
(sort.direction === "asc" ? (
|
||||||
|
<SortAsc className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<SortDesc className="h-3 w-3" />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
<span>Поставки</span>
|
||||||
|
<span>Статус</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Список расходников */}
|
||||||
|
{supplies.map((supply) => {
|
||||||
|
const statusConfig = getStatusConfig(supply.status);
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const isLowStock =
|
||||||
|
supply.currentStock <= supply.minStock && supply.currentStock > 0;
|
||||||
|
const isExpanded = expandedSupplies.has(supply.id);
|
||||||
|
const deliveries = getSupplyDeliveries(supply);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={supply.id}>
|
||||||
|
<Card
|
||||||
|
className="glass-card p-4 hover:bg-white/15 transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={() => onToggleExpansion(supply.id)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-8 gap-3 items-center text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">{supply.name}</p>
|
||||||
|
<p className="text-xs text-white/60 truncate">
|
||||||
|
{supply.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-white/20 text-white/80"
|
||||||
|
>
|
||||||
|
{supply.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-medium text-white">
|
||||||
|
{formatNumber(supply.quantity)} {supply.unit}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-medium text-white">
|
||||||
|
{formatNumber(supply.shippedQuantity || 0)} {supply.unit}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`font-medium ${
|
||||||
|
isLowStock ? "text-yellow-300" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatNumber(supply.currentStock)} {supply.unit}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-white/80 truncate" title={supply.supplier}>
|
||||||
|
{supply.supplier}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 text-xs">
|
||||||
|
{deliveries.length} поставок
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${statusConfig.color} text-xs`}>
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Развернутые поставки для списочного режима */}
|
||||||
|
{isExpanded && (
|
||||||
|
<DeliveryDetails
|
||||||
|
supply={supply}
|
||||||
|
deliveries={deliveries}
|
||||||
|
viewMode="list"
|
||||||
|
getStatusConfig={getStatusConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
167
src/components/fulfillment-warehouse/supplies-stats.tsx
Normal file
167
src/components/fulfillment-warehouse/supplies-stats.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
DollarSign,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SuppliesStatsProps } from "./types";
|
||||||
|
|
||||||
|
export function SuppliesStats({ supplies }: SuppliesStatsProps) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = supplies.length;
|
||||||
|
const available = supplies.filter((s) => s.status === "available").length;
|
||||||
|
const lowStock = supplies.filter((s) => s.status === "low-stock").length;
|
||||||
|
const outOfStock = supplies.filter(
|
||||||
|
(s) => s.status === "out-of-stock"
|
||||||
|
).length;
|
||||||
|
const inTransit = supplies.filter((s) => s.status === "in-transit").length;
|
||||||
|
|
||||||
|
const totalValue = supplies.reduce(
|
||||||
|
(sum, s) => sum + (s.totalCost || s.price * s.quantity),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalStock = supplies.reduce((sum, s) => sum + s.currentStock, 0);
|
||||||
|
|
||||||
|
const categories = [...new Set(supplies.map((s) => s.category))].length;
|
||||||
|
const suppliers = [...new Set(supplies.map((s) => s.supplier))].length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
available,
|
||||||
|
lowStock,
|
||||||
|
outOfStock,
|
||||||
|
inTransit,
|
||||||
|
totalValue,
|
||||||
|
totalStock,
|
||||||
|
categories,
|
||||||
|
suppliers,
|
||||||
|
};
|
||||||
|
}, [supplies]);
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||||
|
{/* Общее количество */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">
|
||||||
|
Всего позиций
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">
|
||||||
|
{formatNumber(stats.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||||
|
<Package className="h-5 w-5 text-blue-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Доступно */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">
|
||||||
|
Доступно
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-green-300 mt-1">
|
||||||
|
{formatNumber(stats.available)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||||
|
<TrendingUp className="h-5 w-5 text-green-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Мало на складе */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">
|
||||||
|
Мало на складе
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-yellow-300 mt-1">
|
||||||
|
{formatNumber(stats.lowStock)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Нет в наличии */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">
|
||||||
|
Нет в наличии
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-red-300 mt-1">
|
||||||
|
{formatNumber(stats.outOfStock)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-red-500/20 rounded-lg">
|
||||||
|
<TrendingDown className="h-5 w-5 text-red-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Общая стоимость */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">
|
||||||
|
Общая стоимость
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-white mt-1">
|
||||||
|
{formatCurrency(stats.totalValue)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||||
|
<DollarSign className="h-5 w-5 text-purple-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Активность */}
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">
|
||||||
|
В пути
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-300 mt-1">
|
||||||
|
{formatNumber(stats.inTransit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/40 mt-1">
|
||||||
|
{stats.categories} категорий
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-orange-500/20 rounded-lg">
|
||||||
|
<Activity className="h-5 w-5 text-orange-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
171
src/components/fulfillment-warehouse/supply-card.tsx
Normal file
171
src/components/fulfillment-warehouse/supply-card.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SupplyCardProps } from "./types";
|
||||||
|
|
||||||
|
export function SupplyCard({
|
||||||
|
supply,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpansion,
|
||||||
|
statusConfig,
|
||||||
|
getSupplyDeliveries,
|
||||||
|
}: SupplyCardProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const isLowStock =
|
||||||
|
supply.currentStock <= supply.minStock && supply.currentStock > 0;
|
||||||
|
const stockPercentage =
|
||||||
|
supply.minStock > 0 ? (supply.currentStock / supply.minStock) * 100 : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Основная карточка расходника */}
|
||||||
|
<Card
|
||||||
|
className="glass-card p-4 hover:bg-white/15 transition-all duration-300 cursor-pointer group"
|
||||||
|
onClick={() => onToggleExpansion(supply.id)}
|
||||||
|
>
|
||||||
|
{/* Заголовок карточки */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-white truncate group-hover:text-blue-300 transition-colors">
|
||||||
|
{supply.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/60 truncate">
|
||||||
|
{supply.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 ml-2">
|
||||||
|
<Badge className={`${statusConfig.color} text-xs`}>
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основная информация */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Остатки и прогресс-бар */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-white/60">Остаток</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
isLowStock ? "text-yellow-300" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatNumber(supply.currentStock)} /{" "}
|
||||||
|
{formatNumber(supply.minStock)} {supply.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(stockPercentage, 100)}
|
||||||
|
className="h-2 bg-white/10"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${
|
||||||
|
stockPercentage > 50
|
||||||
|
? "#10b981"
|
||||||
|
: stockPercentage > 20
|
||||||
|
? "#f59e0b"
|
||||||
|
: "#ef4444"
|
||||||
|
} 0%, ${
|
||||||
|
stockPercentage > 50
|
||||||
|
? "#10b981"
|
||||||
|
: stockPercentage > 20
|
||||||
|
? "#f59e0b"
|
||||||
|
: "#ef4444"
|
||||||
|
} ${Math.min(
|
||||||
|
stockPercentage,
|
||||||
|
100
|
||||||
|
)}%, rgba(255,255,255,0.1) ${Math.min(stockPercentage, 100)}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Метрики */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="p-1 bg-blue-500/20 rounded">
|
||||||
|
<Package className="h-3 w-3 text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-xs">Цена</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(supply.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="p-1 bg-purple-500/20 rounded">
|
||||||
|
<TrendingUp className="h-3 w-3 text-purple-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-xs">Стоимость</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{formatCurrency(
|
||||||
|
supply.totalCost || supply.price * supply.quantity
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дополнительная информация */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-white/20 text-white/80"
|
||||||
|
>
|
||||||
|
{supply.category}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 text-xs">
|
||||||
|
{getSupplyDeliveries(supply).length} поставок
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поставщик и дата */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/60">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span className="truncate max-w-[120px]" title={supply.supplier}>
|
||||||
|
{supply.supplier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{new Date(supply.createdAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
101
src/components/fulfillment-warehouse/types.ts
Normal file
101
src/components/fulfillment-warehouse/types.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
// Основные типы данных
|
||||||
|
export interface Supply {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
supplier: string;
|
||||||
|
minStock: number;
|
||||||
|
currentStock: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
totalCost?: number; // Общая стоимость (количество × цена)
|
||||||
|
shippedQuantity?: number; // Отправленное количество
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
search: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
supplier: string;
|
||||||
|
lowStock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortState {
|
||||||
|
field: "name" | "category" | "status" | "currentStock" | "price" | "supplier";
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusConfig {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryStatusConfig {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "list" | "analytics";
|
||||||
|
export type GroupBy = "none" | "category" | "status" | "supplier";
|
||||||
|
|
||||||
|
// Пропсы для компонентов
|
||||||
|
export interface SupplyCardProps {
|
||||||
|
supply: Supply;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpansion: (id: string) => void;
|
||||||
|
statusConfig: StatusConfig;
|
||||||
|
getSupplyDeliveries: (supply: Supply) => Supply[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppliesGridProps {
|
||||||
|
supplies: Supply[];
|
||||||
|
expandedSupplies: Set<string>;
|
||||||
|
onToggleExpansion: (id: string) => void;
|
||||||
|
getSupplyDeliveries: (supply: Supply) => Supply[];
|
||||||
|
getStatusConfig: (status: string) => StatusConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppliesListProps {
|
||||||
|
supplies: Supply[];
|
||||||
|
expandedSupplies: Set<string>;
|
||||||
|
onToggleExpansion: (id: string) => void;
|
||||||
|
getSupplyDeliveries: (supply: Supply) => Supply[];
|
||||||
|
getStatusConfig: (status: string) => StatusConfig;
|
||||||
|
sort: SortState;
|
||||||
|
onSort: (field: SortState["field"]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppliesHeaderProps {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
groupBy: GroupBy;
|
||||||
|
onGroupByChange: (group: GroupBy) => void;
|
||||||
|
filters: FilterState;
|
||||||
|
onFiltersChange: (filters: FilterState) => void;
|
||||||
|
showFilters: boolean;
|
||||||
|
onToggleFilters: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppliesStatsProps {
|
||||||
|
supplies: Supply[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryDetailsProps {
|
||||||
|
supply: Supply;
|
||||||
|
deliveries: Supply[];
|
||||||
|
viewMode: "grid" | "list";
|
||||||
|
getStatusConfig: (status: string) => StatusConfig;
|
||||||
|
}
|
@ -28,6 +28,7 @@ import {
|
|||||||
GET_MY_COUNTERPARTIES,
|
GET_MY_COUNTERPARTIES,
|
||||||
GET_ALL_PRODUCTS,
|
GET_ALL_PRODUCTS,
|
||||||
GET_SUPPLY_ORDERS,
|
GET_SUPPLY_ORDERS,
|
||||||
|
GET_MY_SUPPLIES,
|
||||||
} from "@/graphql/queries";
|
} from "@/graphql/queries";
|
||||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||||
@ -243,7 +244,10 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
|
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.data?.createSupplyOrder?.success) {
|
if (result.data?.createSupplyOrder?.success) {
|
||||||
|
@ -691,7 +691,7 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Мои расходники
|
// Мои расходники (объединенные данные из supply и supplyOrder)
|
||||||
mySupplies: async (_: unknown, __: unknown, context: Context) => {
|
mySupplies: async (_: unknown, __: unknown, context: Context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new GraphQLError("Требуется авторизация", {
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
@ -708,11 +708,101 @@ export const resolvers = {
|
|||||||
throw new GraphQLError("У пользователя нет организации");
|
throw new GraphQLError("У пользователя нет организации");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.supply.findMany({
|
// Получаем расходники из таблицы supply (уже доставленные)
|
||||||
|
const existingSupplies = await prisma.supply.findMany({
|
||||||
where: { organizationId: currentUser.organization.id },
|
where: { organizationId: currentUser.organization.id },
|
||||||
include: { organization: true },
|
include: { organization: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Получаем заказы поставок, созданные этим фулфилмент-центром для себя
|
||||||
|
// Показываем только заказы, которые еще не доставлены
|
||||||
|
const ourSupplyOrders = await prisma.supplyOrder.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id, // Создали мы
|
||||||
|
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
|
||||||
|
status: {
|
||||||
|
in: ["PENDING", "CONFIRMED", "IN_TRANSIT"], // Только не доставленные заказы
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
partner: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Преобразуем заказы поставок в формат supply для единообразия
|
||||||
|
const suppliesFromOrders = ourSupplyOrders.flatMap((order) =>
|
||||||
|
order.items.map((item) => ({
|
||||||
|
id: `order-${order.id}-${item.id}`,
|
||||||
|
name: item.product.name,
|
||||||
|
description:
|
||||||
|
item.product.description || `Заказ от ${order.partner.name}`,
|
||||||
|
price: item.price,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: "шт",
|
||||||
|
category: item.product.category?.name || "Расходники",
|
||||||
|
status:
|
||||||
|
order.status === "PENDING"
|
||||||
|
? "in-transit"
|
||||||
|
: order.status === "CONFIRMED"
|
||||||
|
? "in-transit"
|
||||||
|
: order.status === "IN_TRANSIT"
|
||||||
|
? "in-transit"
|
||||||
|
: "available",
|
||||||
|
date: order.createdAt,
|
||||||
|
supplier: order.partner.name || order.partner.fullName || "Не указан",
|
||||||
|
minStock: Math.round(item.quantity * 0.1),
|
||||||
|
currentStock: order.status === "DELIVERED" ? item.quantity : 0,
|
||||||
|
imageUrl: null,
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
updatedAt: order.updatedAt,
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
organization: currentUser.organization,
|
||||||
|
shippedQuantity: 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверяем все заказы для этого фулфилмент-центра для отладки
|
||||||
|
const allOurOrders = await prisma.supplyOrder.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
fulfillmentCenterId: currentUser.organization.id,
|
||||||
|
},
|
||||||
|
select: { id: true, status: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
console.log("🔥🔥🔥 MY_SUPPLIES RESOLVER CALLED 🔥🔥🔥");
|
||||||
|
console.log("📊 mySupplies resolver debug:", {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
existingSuppliesCount: existingSupplies.length,
|
||||||
|
ourSupplyOrdersCount: ourSupplyOrders.length,
|
||||||
|
suppliesFromOrdersCount: suppliesFromOrders.length,
|
||||||
|
allOrdersCount: allOurOrders.length,
|
||||||
|
allOrdersStatuses: allOurOrders.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
status: o.status,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
})),
|
||||||
|
filteredOrdersStatuses: ourSupplyOrders.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
status: o.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
console.log("🔥🔥🔥 END MY_SUPPLIES RESOLVER 🔥🔥🔥");
|
||||||
|
|
||||||
|
// Объединяем существующие расходники и расходники из заказов
|
||||||
|
return [...existingSupplies, ...suppliesFromOrders];
|
||||||
},
|
},
|
||||||
|
|
||||||
// Заказы поставок расходников
|
// Заказы поставок расходников
|
||||||
|
Reference in New Issue
Block a user